diff --git a/README.md b/README.md index 8b6ce530..57f5f4b2 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ const chrono = require('chrono-node'); ### What's changed in the v2 For Users * Chrono’s default now handles only international English. While in the previous version, it tried to parse with all known languages. -* In addition to English, Chrono supports the following languages: `fi`, `fr`, `ja`, `nl`, `ru`, and `uk`. We also have partial support for `de`, `es`, `it`, `pt`, `sv`, `zh.hans`, and `zh.hant`. +* In addition to English, Chrono supports the following languages: `fi`, `fr`, `ja`, `nl`, `ru`, `uk`, and `vi`. We also have partial support for `de`, `es`, `it`, `pt`, `sv`, `zh.hans`, and `zh.hant`. For contributors and advanced users * The project is rewritten in TypeScript @@ -212,7 +212,7 @@ chrono.en.GB.parseDate('6/10/2018'); // October 6th, 2018 chrono.ja.parseDate('昭和64年1月7日'); ``` -In addition to English, Chrono supports the following languages: `fi`, `fr`, `ja`, `nl`, `ru`, and `uk`. We also have partial support for `de`, `es`, `it`, `pt`, `sv`, `zh.hans`, and `zh.hant`. +In addition to English, Chrono supports the following languages: `fi`, `fr`, `ja`, `nl`, `ru`, `uk`, and `vi`. We also have partial support for `de`, `es`, `it`, `pt`, `sv`, `zh.hans`, and `zh.hant`. #### Importing specific locales diff --git a/src/index.ts b/src/index.ts index bbcda21d..c2cae6b9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,8 +19,9 @@ import * as uk from "./locales/uk"; import * as it from "./locales/it"; import * as sv from "./locales/sv"; import * as fi from "./locales/fi"; +import * as vi from "./locales/vi"; -export { de, fr, ja, pt, nl, zh, ru, es, uk, it, sv, fi }; +export { de, fr, ja, pt, nl, zh, ru, es, uk, it, sv, fi, vi }; /** * A shortcut for {@link en | chrono.en.strict} diff --git a/src/locales/vi/constants.ts b/src/locales/vi/constants.ts new file mode 100644 index 00000000..02353532 --- /dev/null +++ b/src/locales/vi/constants.ts @@ -0,0 +1,113 @@ +import { matchAnyPattern, repeatedTimeunitPattern } from "../../utils/pattern"; +import { findMostLikelyADYear } from "../../calculation/years"; +import { Duration } from "../../calculation/duration"; +import { Timeunit } from "../../types"; + +export const WEEKDAY_DICTIONARY: { [word: string]: number } = { + "ch\u1ee7 nh\u1eadt": 0, + "cn": 0, + "th\u1ee9 hai": 1, + "t2": 1, + "th\u1ee9 ba": 2, + "t3": 2, + "th\u1ee9 t\u01b0": 3, + "t4": 3, + "th\u1ee9 n\u0103m": 4, + "t5": 4, + "th\u1ee9 s\u00e1u": 5, + "t6": 5, + "th\u1ee9 b\u1ea3y": 6, + "t7": 6, +}; + +export const MONTH_DICTIONARY: { [word: string]: number } = { + "th\u00e1ng 1": 1, + "th\u00e1ng m\u1ed9t": 1, + "th\u00e1ng gi\u00eang": 1, + "th\u00e1ng 2": 2, + "th\u00e1ng hai": 2, + "th\u00e1ng 3": 3, + "th\u00e1ng ba": 3, + "th\u00e1ng 4": 4, + "th\u00e1ng t\u01b0": 4, + "th\u00e1ng 5": 5, + "th\u00e1ng n\u0103m": 5, + "th\u00e1ng 6": 6, + "th\u00e1ng s\u00e1u": 6, + "th\u00e1ng 7": 7, + "th\u00e1ng b\u1ea3y": 7, + "th\u00e1ng 8": 8, + "th\u00e1ng t\u00e1m": 8, + "th\u00e1ng 9": 9, + "th\u00e1ng ch\u00edn": 9, + "th\u00e1ng 10": 10, + "th\u00e1ng m\u01b0\u1eddi": 10, + "th\u00e1ng 11": 11, + "th\u00e1ng m\u01b0\u1eddi m\u1ed9t": 11, + "th\u00e1ng 12": 12, + "th\u00e1ng m\u01b0\u1eddi hai": 12, + "th\u00e1ng ch\u1ea1p": 12, +}; + +export const INTEGER_WORD_DICTIONARY: { [word: string]: number } = { + "m\u1ed9t": 1, + "hai": 2, + "ba": 3, + "b\u1ed1n": 4, + "n\u0103m": 5, + "s\u00e1u": 6, + "b\u1ea3y": 7, + "t\u00e1m": 8, + "ch\u00edn": 9, + "m\u01b0\u1eddi": 10, + "m\u01b0\u1eddi m\u1ed9t": 11, + "m\u01b0\u1eddi hai": 12, +}; + +export const TIME_UNIT_DICTIONARY: { [word: string]: Timeunit } = { + "gi\u00e2y": "second", + "ph\u00fat": "minute", + "gi\u1edd": "hour", + "ng\u00e0y": "day", + "tu\u1ea7n": "week", + "th\u00e1ng": "month", + "n\u0103m": "year", +}; + +export const NUMBER_PATTERN = "(?:" + matchAnyPattern(INTEGER_WORD_DICTIONARY) + "|[0-9]+|[0-9]+\\.[0-9]+)"; + +export function parseNumberPattern(match: string): number { + const num = match.toLowerCase(); + if (INTEGER_WORD_DICTIONARY[num] !== undefined) return INTEGER_WORD_DICTIONARY[num]; + return parseFloat(num); +} + +// YYYY, YYYY TCN (Tr\u01b0\u1edbc C\u00f4ng nguy\u00ean = BC) +export const YEAR_PATTERN = "(?:[0-9]{1,4}(?:\\s*TCN)?)"; + +export function parseYear(match: string): number { + const upper = match.toUpperCase(); + const num = parseInt(match.replace(/[^0-9]+/g, "")); + if (/TCN/.test(upper)) return -num; + return findMostLikelyADYear(num); +} + +const SINGLE_TIME_UNIT_PATTERN = + "(" + NUMBER_PATTERN + ")\\s{0,5}(" + matchAnyPattern(TIME_UNIT_DICTIONARY) + ")\\s{0,5}"; +const SINGLE_TIME_UNIT_REGEX = new RegExp(SINGLE_TIME_UNIT_PATTERN, "i"); + +export const TIME_UNITS_PATTERN = repeatedTimeunitPattern("", SINGLE_TIME_UNIT_PATTERN); + +export function parseDuration(timeunitText: string): Duration { + const fragments: { [key: string]: number } = {}; + let remainingText = timeunitText; + let match = SINGLE_TIME_UNIT_REGEX.exec(remainingText); + while (match) { + const num = parseNumberPattern(match[1]); + const unit = TIME_UNIT_DICTIONARY[match[2].toLowerCase()]; + fragments[unit] = num; + remainingText = remainingText.substring(match[0].length); + match = SINGLE_TIME_UNIT_REGEX.exec(remainingText); + } + return fragments as Duration; +} diff --git a/src/locales/vi/index.ts b/src/locales/vi/index.ts new file mode 100644 index 00000000..118c03db --- /dev/null +++ b/src/locales/vi/index.ts @@ -0,0 +1,73 @@ +/** + * Chrono components for Vietnamese support (*parsers*, *refiners*, and *configuration*) + * + * @module + */ +import { includeCommonConfiguration } from "../../configurations"; +import { Chrono, Configuration, Parser, Refiner } from "../../chrono"; +import { ParsingResult, ParsingComponents, ReferenceWithTimezone } from "../../results"; +import { Component, ParsedResult, ParsingOption, ParsingReference, Meridiem, Weekday } from "../../types"; +import ISOFormatParser from "../../common/parsers/ISOFormatParser"; +import SlashDateFormatParser from "../../common/parsers/SlashDateFormatParser"; +import VIStandardParser from "./parsers/VIStandardParser"; +import VIMonthYearParser from "./parsers/VIMonthYearParser"; +import VIYearParser from "./parsers/VIYearParser"; +import VICasualDateParser from "./parsers/VICasualDateParser"; +import VICasualTimeParser from "./parsers/VICasualTimeParser"; +import VIWeekdayParser from "./parsers/VIWeekdayParser"; +import VITimeExpressionParser from "./parsers/VITimeExpressionParser"; +import VITimeUnitAgoFormatParser from "./parsers/VITimeUnitAgoFormatParser"; +import VITimeUnitLaterFormatParser from "./parsers/VITimeUnitLaterFormatParser"; +import VITimeUnitWithinFormatParser from "./parsers/VITimeUnitWithinFormatParser"; +import VITimeUnitCasualRelativeFormatParser from "./parsers/VITimeUnitCasualRelativeFormatParser"; +import VIMergeDateRangeRefiner from "./refiners/VIMergeDateRangeRefiner"; +import VIMergeDateTimeRefiner from "./refiners/VIMergeDateTimeRefiner"; +import VIMergeWeekdayComponentRefiner from "./refiners/VIMergeWeekdayComponentRefiner"; + +export { Chrono, Parser, Refiner, ParsingResult, ParsingComponents, ReferenceWithTimezone }; +export { Component, ParsedResult, ParsingOption, ParsingReference, Meridiem, Weekday }; + +// Shortcuts +export const casual = new Chrono(createCasualConfiguration()); +export const strict = new Chrono(createConfiguration(true)); + +export function parse(text: string, ref?: ParsingReference | Date, option?: ParsingOption): ParsedResult[] { + return casual.parse(text, ref, option); +} + +export function parseDate(text: string, ref?: ParsingReference | Date, option?: ParsingOption): Date { + return casual.parseDate(text, ref, option); +} + +export function createCasualConfiguration(littleEndian = true): Configuration { + const option = createConfiguration(false, littleEndian); + option.parsers.unshift(new VICasualTimeParser()); + option.parsers.unshift(new VICasualDateParser()); + option.parsers.unshift(new VITimeUnitCasualRelativeFormatParser()); + return option; +} + +export function createConfiguration(strictMode = true, littleEndian = true): Configuration { + return includeCommonConfiguration( + { + parsers: [ + new ISOFormatParser(), + new SlashDateFormatParser(littleEndian), + new VIStandardParser(), + new VIMonthYearParser(), + new VIYearParser(), + new VIWeekdayParser(), + new VITimeExpressionParser(), + new VITimeUnitAgoFormatParser(strictMode), + new VITimeUnitLaterFormatParser(strictMode), + new VITimeUnitWithinFormatParser(strictMode), + ], + refiners: [ + new VIMergeWeekdayComponentRefiner(), + new VIMergeDateRangeRefiner(), + new VIMergeDateTimeRefiner(), + ], + }, + strictMode + ); +} diff --git a/src/locales/vi/parsers/VICasualDateParser.ts b/src/locales/vi/parsers/VICasualDateParser.ts new file mode 100644 index 00000000..36aa0c63 --- /dev/null +++ b/src/locales/vi/parsers/VICasualDateParser.ts @@ -0,0 +1,31 @@ +import { ParsingContext } from "../../../chrono"; +import { ParsingComponents } from "../../../results"; +import { AbstractParserWithWordBoundaryChecking } from "../../../common/parsers/AbstractParserWithWordBoundary"; +import * as references from "../../../common/casualReferences"; + +const PATTERN = /\b(hôm nay|hôm qua|hôm kia|ngày mai|ngày kia|bây giờ|lúc này)(?=\W|$)/i; + +export default class VICasualDateParser extends AbstractParserWithWordBoundaryChecking { + innerPattern(): RegExp { + return PATTERN; + } + + innerExtract(context: ParsingContext, match: RegExpMatchArray): ParsingComponents { + switch (match[1].toLowerCase()) { + case "bây giờ": + case "lúc này": + return references.now(context.reference); + case "hôm nay": + return references.today(context.reference); + case "hôm qua": + return references.yesterday(context.reference); + case "hôm kia": + return references.theDayBefore(context.reference, 2); + case "ngày mai": + return references.tomorrow(context.reference); + case "ngày kia": + return references.theDayAfter(context.reference, 2); + } + return context.createParsingComponents(); + } +} diff --git a/src/locales/vi/parsers/VICasualTimeParser.ts b/src/locales/vi/parsers/VICasualTimeParser.ts new file mode 100644 index 00000000..8ec0d589 --- /dev/null +++ b/src/locales/vi/parsers/VICasualTimeParser.ts @@ -0,0 +1,63 @@ +import { ParsingContext } from "../../../chrono"; +import { ParsingComponents, ParsingResult } from "../../../results"; +import { Meridiem } from "../../../types"; +import { AbstractParserWithWordBoundaryChecking } from "../../../common/parsers/AbstractParserWithWordBoundary"; +import { implySimilarTime } from "../../../utils/dates"; + +// bu\u1ed5i s\u00e1ng | bu\u1ed5i tr\u01b0a | bu\u1ed5i chi\u1ec1u | bu\u1ed5i t\u1ed1i | n\u1eeda \u0111\u00eam +const PATTERN = + /(buổi\s*)?(sáng sớm|sáng|trưa|chiều|tối|đêm|nửa đêm|bình minh)(?=\W|$)/i; + +export default class VICasualTimeParser extends AbstractParserWithWordBoundaryChecking { + innerPattern(): RegExp { + return PATTERN; + } + + innerExtract(context: ParsingContext, match: RegExpMatchArray): ParsingComponents | ParsingResult { + const component = context.createParsingComponents(); + implySimilarTime(component, context.refDate); + return VICasualTimeParser.extractTimeComponents(component, match[2].toLowerCase()); + } + + static extractTimeComponents(component: ParsingComponents, keyword: string): ParsingComponents { + switch (keyword) { + case "b\u00ecnh minh": + case "s\u00e1ng s\u1edbm": + component.imply("hour", 6); + component.imply("minute", 0); + component.imply("meridiem", Meridiem.AM); + break; + case "s\u00e1ng": + component.imply("hour", 9); + component.imply("minute", 0); + component.imply("meridiem", Meridiem.AM); + break; + case "tr\u01b0a": + component.imply("hour", 12); + component.imply("minute", 0); + component.imply("meridiem", Meridiem.PM); // noon = 12:00 PM in chrono's 12-hour convention + break; + case "chi\u1ec1u": + component.imply("hour", 15); + component.imply("minute", 0); + component.imply("meridiem", Meridiem.PM); + break; + case "t\u1ed1i": + component.imply("hour", 19); + component.imply("minute", 0); + component.imply("meridiem", Meridiem.PM); + break; + case "\u0111\u00eam": + component.imply("hour", 22); + component.imply("minute", 0); + component.imply("meridiem", Meridiem.PM); + break; + case "n\u1eeda \u0111\u00eam": + component.imply("hour", 0); + component.imply("minute", 0); + component.imply("meridiem", Meridiem.AM); + break; + } + return component; + } +} diff --git a/src/locales/vi/parsers/VIMonthYearParser.ts b/src/locales/vi/parsers/VIMonthYearParser.ts new file mode 100644 index 00000000..54528e94 --- /dev/null +++ b/src/locales/vi/parsers/VIMonthYearParser.ts @@ -0,0 +1,35 @@ +import { ParsingContext } from "../../../chrono"; +import { ParsingResult } from "../../../results"; +import { MONTH_DICTIONARY, YEAR_PATTERN, parseYear } from "../constants"; +import { matchAnyPattern } from "../../../utils/pattern"; +import { AbstractParserWithWordBoundaryChecking } from "../../../common/parsers/AbstractParserWithWordBoundary"; + +// tháng 3 năm 1975 | tháng ba năm 1975 | tháng chạp | tháng giêng/1975 +const PATTERN = new RegExp( + "(" + matchAnyPattern(MONTH_DICTIONARY) + ")" + "(?:\\s*(?:năm|/)\\s*(" + YEAR_PATTERN + "))?" + "(?=\\W|$)", + "i" +); + +const MONTH_GROUP = 1; +const YEAR_GROUP = 2; + +export default class VIMonthYearParser extends AbstractParserWithWordBoundaryChecking { + innerPattern(): RegExp { + return PATTERN; + } + + innerExtract(context: ParsingContext, match: RegExpMatchArray): ParsingResult { + const month = MONTH_DICTIONARY[match[MONTH_GROUP].toLowerCase()]; + if (!month) return null; + + const result = context.createParsingResult(match.index, match[0]); + result.start.assign("month", month); + result.start.imply("day", 1); + if (match[YEAR_GROUP]) { + result.start.assign("year", parseYear(match[YEAR_GROUP])); + } else { + result.start.imply("year", context.reference.getDateWithAdjustedTimezone().getFullYear()); + } + return result; + } +} diff --git a/src/locales/vi/parsers/VIStandardParser.ts b/src/locales/vi/parsers/VIStandardParser.ts new file mode 100644 index 00000000..4ee73e92 --- /dev/null +++ b/src/locales/vi/parsers/VIStandardParser.ts @@ -0,0 +1,45 @@ +import { ParsingContext } from "../../../chrono"; +import { ParsingResult } from "../../../results"; +import { findYearClosestToRef } from "../../../calculation/years"; +import { YEAR_PATTERN, parseYear } from "../constants"; +import { AbstractParserWithWordBoundaryChecking } from "../../../common/parsers/AbstractParserWithWordBoundary"; + +// ngày 15 tháng 3 năm 1975 | 15 tháng 3 năm 1975 | 15 tháng 3 +const PATTERN = new RegExp( + "(?:ng\u00e0y\\s*)?" + + "([0-9]{1,2})" + + "\\s*th\u00e1ng\\s*" + + "([0-9]{1,2})" + + "(?:\\s*n\u0103m\\s*(" + + YEAR_PATTERN + + "))?" + + "(?=\\W|$)", + "i" +); + +const DAY_GROUP = 1; +const MONTH_GROUP = 2; +const YEAR_GROUP = 3; + +export default class VIStandardParser extends AbstractParserWithWordBoundaryChecking { + innerPattern(): RegExp { + return PATTERN; + } + + innerExtract(context: ParsingContext, match: RegExpMatchArray): ParsingResult { + const day = parseInt(match[DAY_GROUP]); + const month = parseInt(match[MONTH_GROUP]); + if (day > 31 || month > 12) return null; + + const result = context.createParsingResult(match.index, match[0]); + result.start.assign("day", day); + result.start.assign("month", month); + + if (match[YEAR_GROUP]) { + result.start.assign("year", parseYear(match[YEAR_GROUP])); + } else { + result.start.imply("year", findYearClosestToRef(context.refDate, day, month)); + } + return result; + } +} diff --git a/src/locales/vi/parsers/VITimeExpressionParser.ts b/src/locales/vi/parsers/VITimeExpressionParser.ts new file mode 100644 index 00000000..aa3cd7d0 --- /dev/null +++ b/src/locales/vi/parsers/VITimeExpressionParser.ts @@ -0,0 +1,66 @@ +import { ParsingContext } from "../../../chrono"; +import { ParsingResult } from "../../../results"; +import { AbstractParserWithWordBoundaryChecking } from "../../../common/parsers/AbstractParserWithWordBoundary"; +import { Meridiem } from "../../../types"; + +// l\u00fac 7 gi\u1edd 30 ph\u00fat | 7 gi\u1edd s\u00e1ng | 7 gi\u1edd | 15:30 +const PATTERN = new RegExp( + "(?:l\u00fac\\s*|v\u00e0o\\s*)?" + + "([0-9]{1,2})" + + "(?:\\s*gi\u1edd\\s*([0-9]{1,2})?\\s*(?:ph\u00fat\\s*)?" + + "(s\u00e1ng|tr\u01b0a|chi\u1ec1u|t\u1ed1i|\u0111\u00eam)?" + + "|:([0-9]{2}))" + + "(?=\\W|$)", + "i" +); + +const HOUR_GROUP = 1; +const MINUTE_GIO_GROUP = 2; +const MERIDIEM_GROUP = 3; +const MINUTE_COLON_GROUP = 4; + +export default class VITimeExpressionParser extends AbstractParserWithWordBoundaryChecking { + innerPattern(): RegExp { + return PATTERN; + } + + innerExtract(context: ParsingContext, match: RegExpMatchArray): ParsingResult { + const hour = parseInt(match[HOUR_GROUP]); + if (hour > 23) return null; + + const result = context.createParsingResult(match.index, match[0]); + result.start.assign("hour", hour); + + const minute = match[MINUTE_COLON_GROUP] + ? parseInt(match[MINUTE_COLON_GROUP]) + : match[MINUTE_GIO_GROUP] + ? parseInt(match[MINUTE_GIO_GROUP]) + : 0; + if (minute >= 60) return null; + result.start.assign("minute", minute); + + const meridiem = match[MERIDIEM_GROUP]?.toLowerCase(); + if (meridiem === "sáng") { + // "12 giờ sáng" = midnight (00:00), matching EN convention + result.start.assign("meridiem", Meridiem.AM); + if (hour === 12) result.start.assign("hour", 0); + } else if (meridiem === "trưa") { + // "trưa" = noon/midday (~11 AM - 1 PM) + // "1 giờ trưa" = 13:00, but "11 giờ trưa" = 11:00 (approaching noon) + if (hour < 10) { + result.start.assign("meridiem", Meridiem.PM); + result.start.assign("hour", hour + 12); + } else { + // 10-12: keep as-is (AM for 10-11, PM for 12) + result.start.assign("meridiem", hour >= 12 ? Meridiem.PM : Meridiem.AM); + } + } else if (meridiem === "chiều" || meridiem === "tối" || meridiem === "đêm") { + result.start.assign("meridiem", Meridiem.PM); + if (hour < 12) result.start.assign("hour", hour + 12); + } + + result.start.imply("second", 0); + result.start.imply("millisecond", 0); + return result; + } +} diff --git a/src/locales/vi/parsers/VITimeUnitAgoFormatParser.ts b/src/locales/vi/parsers/VITimeUnitAgoFormatParser.ts new file mode 100644 index 00000000..89e8f39f --- /dev/null +++ b/src/locales/vi/parsers/VITimeUnitAgoFormatParser.ts @@ -0,0 +1,23 @@ +import { ParsingContext } from "../../../chrono"; +import { parseDuration, TIME_UNITS_PATTERN } from "../constants"; +import { ParsingComponents } from "../../../results"; +import { AbstractParserWithWordBoundaryChecking } from "../../../common/parsers/AbstractParserWithWordBoundary"; +import { reverseDuration } from "../../../calculation/duration"; + +// 2 ng\u00e0y tr\u01b0\u1edbc | 3 th\u00e1ng qua | 1 n\u0103m tr\u01b0\u1edbc +const PATTERN = new RegExp("(" + TIME_UNITS_PATTERN + ")" + "\\s{0,5}(?:tr\u01b0\u1edbc|qua)(?=\\W|$)", "i"); +export default class VITimeUnitAgoFormatParser extends AbstractParserWithWordBoundaryChecking { + constructor(private strictMode = false) { + super(); + } + innerPattern(): RegExp { + // VI has no unit abbreviations, so strict and casual patterns are identical. + return PATTERN; + } + + innerExtract(context: ParsingContext, match: RegExpMatchArray): ParsingComponents { + const duration = parseDuration(match[1]); + if (!duration) return null; + return ParsingComponents.createRelativeFromReference(context.reference, reverseDuration(duration)); + } +} diff --git a/src/locales/vi/parsers/VITimeUnitCasualRelativeFormatParser.ts b/src/locales/vi/parsers/VITimeUnitCasualRelativeFormatParser.ts new file mode 100644 index 00000000..dfb7e12e --- /dev/null +++ b/src/locales/vi/parsers/VITimeUnitCasualRelativeFormatParser.ts @@ -0,0 +1,46 @@ +import { TIME_UNIT_DICTIONARY, NUMBER_PATTERN, parseDuration } from "../constants"; +import { ParsingContext } from "../../../chrono"; +import { ParsingComponents } from "../../../results"; +import { AbstractParserWithWordBoundaryChecking } from "../../../common/parsers/AbstractParserWithWordBoundary"; +import { reverseDuration } from "../../../calculation/duration"; +import { matchAnyPattern } from "../../../utils/pattern"; + +// tuần này | tháng trước | năm sau | tuần tới | 2 tuần trước +// NUMBER is optional so bare unit words ("tuần này") are matched +const CASUAL_UNIT_PATTERN = "(?:" + NUMBER_PATTERN + "\\s{0,5})?(?:" + matchAnyPattern(TIME_UNIT_DICTIONARY) + ")"; + +const PATTERN = new RegExp( + "(này|trước|qua|sau|tới|tiếp)\\s*(" + + CASUAL_UNIT_PATTERN + + ")" + + "|(" + + CASUAL_UNIT_PATTERN + + ")\\s*(này|trước|qua|sau|tới|tiếp)" + + "(?=\\W|$)", + "i" +); + +export default class VITimeUnitCasualRelativeFormatParser extends AbstractParserWithWordBoundaryChecking { + innerPattern(): RegExp { + return PATTERN; + } + + innerExtract(context: ParsingContext, match: RegExpMatchArray): ParsingComponents { + // prefix form (trước tuần) or suffix form (tuần trước) + const modifier = (match[1] || match[4] || "").toLowerCase(); + const unitText = (match[2] || match[3] || "").toLowerCase(); + + let duration = parseDuration(unitText); + if (Object.keys(duration).length === 0) { + // bare unit word with no number — implies 1 + const unit = TIME_UNIT_DICTIONARY[unitText]; + if (!unit) return null; + duration = { [unit]: 1 }; + } + + if (modifier === "trước" || modifier === "qua") { + duration = reverseDuration(duration); + } + return ParsingComponents.createRelativeFromReference(context.reference, duration); + } +} diff --git a/src/locales/vi/parsers/VITimeUnitLaterFormatParser.ts b/src/locales/vi/parsers/VITimeUnitLaterFormatParser.ts new file mode 100644 index 00000000..1ebe9f3e --- /dev/null +++ b/src/locales/vi/parsers/VITimeUnitLaterFormatParser.ts @@ -0,0 +1,25 @@ +import { ParsingContext } from "../../../chrono"; +import { parseDuration, TIME_UNITS_PATTERN } from "../constants"; +import { ParsingComponents } from "../../../results"; +import { AbstractParserWithWordBoundaryChecking } from "../../../common/parsers/AbstractParserWithWordBoundary"; + +// 2 ng\u00e0y sau | 3 tu\u1ea7n n\u1eefa | 1 th\u00e1ng t\u1edbi +const PATTERN = new RegExp( + "(" + TIME_UNITS_PATTERN + ")" + "\\s{0,5}(?:sau|n\u1eefa|t\u1edbi|ti\u1ebfp)(?=\\W|$)", + "i" +); +export default class VITimeUnitLaterFormatParser extends AbstractParserWithWordBoundaryChecking { + constructor(private strictMode = false) { + super(); + } + innerPattern(): RegExp { + // VI has no unit abbreviations, so strict and casual patterns are identical. + return PATTERN; + } + + innerExtract(context: ParsingContext, match: RegExpMatchArray): ParsingComponents { + const duration = parseDuration(match[1]); + if (!duration) return null; + return ParsingComponents.createRelativeFromReference(context.reference, duration); + } +} diff --git a/src/locales/vi/parsers/VITimeUnitWithinFormatParser.ts b/src/locales/vi/parsers/VITimeUnitWithinFormatParser.ts new file mode 100644 index 00000000..17bd21c1 --- /dev/null +++ b/src/locales/vi/parsers/VITimeUnitWithinFormatParser.ts @@ -0,0 +1,22 @@ +import { ParsingContext } from "../../../chrono"; +import { parseDuration, TIME_UNITS_PATTERN } from "../constants"; +import { ParsingComponents } from "../../../results"; +import { AbstractParserWithWordBoundaryChecking } from "../../../common/parsers/AbstractParserWithWordBoundary"; + +// trong 2 ng\u00e0y | trong 3 tu\u1ea7n | trong v\u00f2ng 1 th\u00e1ng +const PATTERN = new RegExp("(?:trong\\s*(?:v\u00f2ng\\s*)?)" + "(" + TIME_UNITS_PATTERN + ")(?=\\W|$)", "i"); +export default class VITimeUnitWithinFormatParser extends AbstractParserWithWordBoundaryChecking { + constructor(private strictMode = false) { + super(); + } + innerPattern(): RegExp { + // VI has no unit abbreviations, so strict and casual patterns are identical. + return PATTERN; + } + + innerExtract(context: ParsingContext, match: RegExpMatchArray): ParsingComponents { + const timeUnits = parseDuration(match[1]); + if (!timeUnits) return null; + return ParsingComponents.createRelativeFromReference(context.reference, timeUnits); + } +} diff --git a/src/locales/vi/parsers/VIWeekdayParser.ts b/src/locales/vi/parsers/VIWeekdayParser.ts new file mode 100644 index 00000000..2a984397 --- /dev/null +++ b/src/locales/vi/parsers/VIWeekdayParser.ts @@ -0,0 +1,41 @@ +import { ParsingContext } from "../../../chrono"; +import { ParsingComponents } from "../../../results"; +import { WEEKDAY_DICTIONARY } from "../constants"; +import { matchAnyPattern } from "../../../utils/pattern"; +import { AbstractParserWithWordBoundaryChecking } from "../../../common/parsers/AbstractParserWithWordBoundary"; +import { createParsingComponentsAtWeekday } from "../../../calculation/weekdays"; + +const PATTERN = new RegExp( + "(" + + matchAnyPattern(WEEKDAY_DICTIONARY) + + ")" + + // 'sau khi' is a conjunction ("after when") — exclude via negative lookahead + "(?:\\s*(này|tới|sau(?!\\s*khi)|qua))?" + + "(?=\\W|$)", + "i" +); + +const WEEKDAY_GROUP = 1; +const MODIFIER_GROUP = 2; + +export default class VIWeekdayParser extends AbstractParserWithWordBoundaryChecking { + innerPattern(): RegExp { + return PATTERN; + } + + innerExtract(context: ParsingContext, match: RegExpMatchArray): ParsingComponents { + const dowText = match[WEEKDAY_GROUP].toLowerCase(); + const dow = WEEKDAY_DICTIONARY[dowText]; + if (dow === undefined) return null; + + const modifier = match[MODIFIER_GROUP]; + let modifierType: "this" | "next" | "last" | null = null; + if (modifier) { + const m = modifier.toLowerCase(); + if (m.includes("tới") || m.includes("sau")) modifierType = "next"; + else if (m.includes("qua")) modifierType = "last"; + } + + return createParsingComponentsAtWeekday(context.reference, dow, modifierType); + } +} diff --git a/src/locales/vi/parsers/VIYearParser.ts b/src/locales/vi/parsers/VIYearParser.ts new file mode 100644 index 00000000..6e3440e4 --- /dev/null +++ b/src/locales/vi/parsers/VIYearParser.ts @@ -0,0 +1,31 @@ +import { ParsingContext } from "../../../chrono"; +import { ParsingResult } from "../../../results"; +import { YEAR_PATTERN, parseYear } from "../constants"; +import { AbstractParserWithWordBoundaryChecking } from "../../../common/parsers/AbstractParserWithWordBoundary"; + +// năm 1975 | năm 43 TCN | 179 TCN (bare BC year without năm prefix) +const PATTERN = new RegExp("(?:\\bnăm\\s*(" + YEAR_PATTERN + ")|\\b([0-9]{1,4})\\s*(TCN))(?=\\W|$)", "i"); + +const YEAR_WITH_NAM_GROUP = 1; // năm YYYY or năm YYYY TCN +const BARE_BC_YEAR_GROUP = 2; // bare YYYY in "YYYY TCN" +const BARE_BC_SUFFIX_GROUP = 3; // "TCN" in bare form + +export default class VIYearParser extends AbstractParserWithWordBoundaryChecking { + innerPattern(): RegExp { + return PATTERN; + } + + innerExtract(context: ParsingContext, match: RegExpMatchArray): ParsingResult { + let yearText: string; + if (match[YEAR_WITH_NAM_GROUP]) { + yearText = match[YEAR_WITH_NAM_GROUP]; + } else { + yearText = match[BARE_BC_YEAR_GROUP] + " " + match[BARE_BC_SUFFIX_GROUP]; + } + const result = context.createParsingResult(match.index, match[0]); + result.start.assign("year", parseYear(yearText)); + result.start.imply("month", 1); + result.start.imply("day", 1); + return result; + } +} diff --git a/src/locales/vi/refiners/VIMergeDateRangeRefiner.ts b/src/locales/vi/refiners/VIMergeDateRangeRefiner.ts new file mode 100644 index 00000000..4c6b10e9 --- /dev/null +++ b/src/locales/vi/refiners/VIMergeDateRangeRefiner.ts @@ -0,0 +1,7 @@ +import AbstractMergeDateRangeRefiner from "../../../common/refiners/AbstractMergeDateRangeRefiner"; + +export default class VIMergeDateRangeRefiner extends AbstractMergeDateRangeRefiner { + patternBetween(): RegExp { + return /^\s*(?:–|-|đến|tới|và)\s*$/; + } +} diff --git a/src/locales/vi/refiners/VIMergeDateTimeRefiner.ts b/src/locales/vi/refiners/VIMergeDateTimeRefiner.ts new file mode 100644 index 00000000..f817d71d --- /dev/null +++ b/src/locales/vi/refiners/VIMergeDateTimeRefiner.ts @@ -0,0 +1,7 @@ +import AbstractMergeDateTimeRefiner from "../../../common/refiners/AbstractMergeDateTimeRefiner"; + +export default class VIMergeDateTimeRefiner extends AbstractMergeDateTimeRefiner { + patternBetween(): RegExp { + return /^\s*(?:lúc|vào|,|T|-)?\s*$/; + } +} diff --git a/src/locales/vi/refiners/VIMergeWeekdayComponentRefiner.ts b/src/locales/vi/refiners/VIMergeWeekdayComponentRefiner.ts new file mode 100644 index 00000000..ba36a877 --- /dev/null +++ b/src/locales/vi/refiners/VIMergeWeekdayComponentRefiner.ts @@ -0,0 +1,7 @@ +import MergeWeekdayComponentRefiner from "../../../common/refiners/MergeWeekdayComponentRefiner"; + +/** + * Merge weekday with adjacent date/time component. + * e.g. [th\u1ee9 Hai] [l\u00fac 7 gi\u1edd] => [th\u1ee9 Hai l\u00fac 7 gi\u1edd] + */ +export default class VIMergeWeekdayComponentRefiner extends MergeWeekdayComponentRefiner {} diff --git a/test/vi/vi_casual.test.ts b/test/vi/vi_casual.test.ts new file mode 100644 index 00000000..552685e9 --- /dev/null +++ b/test/vi/vi_casual.test.ts @@ -0,0 +1,84 @@ +import * as chrono from "../../src/"; +import { testSingleCase } from "../test_util"; + +test("Test - h\u00f4m nay (today)", () => { + testSingleCase(chrono.vi, "Cu\u1ed9c h\u1ecdn h\u00f4m nay.", new Date(2012, 7, 10, 12), (r) => { + expect(r.index).toBe(9); + expect(r.text).toBe("h\u00f4m nay"); + expect(r.start.get("year")).toBe(2012); + expect(r.start.get("month")).toBe(8); + expect(r.start.get("day")).toBe(10); + expect(r.start).toBeDate(new Date(2012, 7, 10, 12)); + }); +}); + +test("Test - h\u00f4m qua (yesterday)", () => { + testSingleCase(chrono.vi, "H\u1ed9i ngh\u1ecb h\u00f4m qua.", new Date(2012, 7, 10, 12), (r) => { + expect(r.index).toBe(9); + expect(r.text).toBe("h\u00f4m qua"); + expect(r.start.get("year")).toBe(2012); + expect(r.start.get("month")).toBe(8); + expect(r.start.get("day")).toBe(9); + expect(r.start).toBeDate(new Date(2012, 7, 9, 12)); + }); + + testSingleCase(chrono.vi, "h\u00f4m qua", new Date(2012, 7, 1, 12), (r) => { + expect(r.start.get("month")).toBe(7); // July — crosses month boundary + expect(r.start.get("day")).toBe(31); + }); +}); + +test("Test - ng\u00e0y mai (tomorrow)", () => { + testSingleCase(chrono.vi, "L\u1ecbch ng\u00e0y mai.", new Date(2012, 7, 10, 12), (r) => { + expect(r.index).toBe(5); + expect(r.text).toBe("ng\u00e0y mai"); + expect(r.start.get("day")).toBe(11); + expect(r.start.get("month")).toBe(8); + expect(r.start).toBeDate(new Date(2012, 7, 11, 12)); + }); + + testSingleCase(chrono.vi, "ng\u00e0y mai", new Date(2012, 7, 31, 12), (r) => { + expect(r.start.get("month")).toBe(9); // Sept — crosses month boundary + expect(r.start.get("day")).toBe(1); + }); +}); + +test("Test - ng\u00e0y kia (day after tomorrow)", () => { + testSingleCase(chrono.vi, "ng\u00e0y kia", new Date(2012, 7, 10, 12), (r) => { + expect(r.text).toBe("ng\u00e0y kia"); + expect(r.start.get("day")).toBe(12); + }); +}); + +test("Test - b\u00e2y gi\u1edd / l\u00fac n\u00e0y (now)", () => { + testSingleCase(chrono.vi, "b\u00e2y gi\u1edd", new Date(2012, 7, 10, 8, 9, 10, 11), (r) => { + expect(r.start.get("year")).toBe(2012); + expect(r.start.get("month")).toBe(8); + expect(r.start.get("day")).toBe(10); + expect(r.start.get("hour")).toBe(8); + expect(r.start.get("minute")).toBe(9); + expect(r.start.get("second")).toBe(10); + expect(r.start).toBeDate(new Date(2012, 7, 10, 8, 9, 10, 11)); + }); + + testSingleCase(chrono.vi, "l\u00fac n\u00e0y", new Date(2012, 7, 10, 8, 9, 10, 11), (r) => { + expect(r.start.get("hour")).toBe(8); + }); +}); + +test("Test - hôm kia (day before yesterday, -2)", () => { + testSingleCase(chrono.vi, "hôm kia", new Date(2012, 7, 10, 12), (r) => { + expect(r.start.get("year")).toBe(2012); + expect(r.start.get("month")).toBe(8); + expect(r.start.get("day")).toBe(8); // 10 - 2 + }); +}); + +test("Test - isCertain: casual date sets day/month/year certain, hour not", () => { + testSingleCase(chrono.vi, "hôm nay", new Date(2012, 7, 10, 12), (r) => { + expect(r.start.isCertain("day")).toBe(true); + expect(r.start.isCertain("month")).toBe(true); + expect(r.start.isCertain("year")).toBe(true); + expect(r.start.isCertain("hour")).toBe(false); + }); +}); diff --git a/test/vi/vi_casual_time.test.ts b/test/vi/vi_casual_time.test.ts new file mode 100644 index 00000000..e533b0d1 --- /dev/null +++ b/test/vi/vi_casual_time.test.ts @@ -0,0 +1,77 @@ +import * as chrono from "../../src/"; +import { testSingleCase } from "../test_util"; + +const REF = new Date(2012, 7, 10, 12); // 2012-08-10 noon + +test("Test - sáng (morning, AM)", () => { + // "buổi sáng" is a time-of-day modifier; test it with an explicit time expression + testSingleCase(chrono.vi, "7 giờ sáng", REF, (r) => { + expect(r.start.get("hour")).toBe(7); + expect(r.start.get("meridiem")).toBe(0); // AM + }); + // standalone "sáng" sets implied hour=9, AM + testSingleCase(chrono.vi, "hôm nay buổi sáng", new Date(2012, 7, 10, 6), (r) => { + expect(r.start.get("hour")).toBe(9); + expect(r.start.get("meridiem")).toBe(0); // AM + }); +}); + +test("Test - trưa (noon, PM)", () => { + testSingleCase(chrono.vi, "buổi trưa", REF, (r) => { + expect(r.start.get("hour")).toBe(12); + expect(r.start.get("meridiem")).toBe(1); // PM — noon is 12:00 PM + }); +}); + +test("Test - chiều (afternoon, PM)", () => { + testSingleCase(chrono.vi, "buổi chiều", REF, (r) => { + expect(r.start.get("hour")).toBe(15); + expect(r.start.get("meridiem")).toBe(1); // PM + }); +}); + +test("Test - tối (evening, PM)", () => { + testSingleCase(chrono.vi, "buổi tối", REF, (r) => { + expect(r.start.get("hour")).toBe(19); + expect(r.start.get("meridiem")).toBe(1); // PM + }); +}); + +test("Test - đêm (late night, PM)", () => { + testSingleCase(chrono.vi, "buổi đêm", REF, (r) => { + expect(r.start.get("hour")).toBe(22); + expect(r.start.get("meridiem")).toBe(1); // PM + }); + + // Standalone "đêm" without "buổi" prefix — works after removing \b from pattern + testSingleCase(chrono.vi, "đêm", REF, (r) => { + expect(r.start.get("hour")).toBe(22); + expect(r.start.get("meridiem")).toBe(1); // PM + }); +}); + +test("Test - nửa đêm (midnight, AM)", () => { + testSingleCase(chrono.vi, "nửa đêm", REF, (r) => { + expect(r.start.get("hour")).toBe(0); + expect(r.start.get("meridiem")).toBe(0); // AM + }); +}); + +test("Test - bình minh / sáng sớm (dawn)", () => { + testSingleCase(chrono.vi, "bình minh", REF, (r) => { + expect(r.start.get("hour")).toBe(6); + expect(r.start.get("meridiem")).toBe(0); // AM + }); + + testSingleCase(chrono.vi, "sáng sớm", REF, (r) => { + expect(r.start.get("hour")).toBe(6); + expect(r.start.get("meridiem")).toBe(0); // AM + }); +}); + +test("Test - time keyword merges with date", () => { + testSingleCase(chrono.vi, "hôm nay buổi chiều", REF, (r) => { + expect(r.start.get("day")).toBe(10); + expect(r.start.get("hour")).toBe(15); + }); +}); diff --git a/test/vi/vi_date_range.test.ts b/test/vi/vi_date_range.test.ts new file mode 100644 index 00000000..940c6f06 --- /dev/null +++ b/test/vi/vi_date_range.test.ts @@ -0,0 +1,61 @@ +import * as chrono from "../../src/"; +import { testSingleCase } from "../test_util"; + +const REF = new Date(2012, 7, 10, 12); // 2012-08-10 noon + +test("Test - từ ngày X đến ngày Y (đến connector)", () => { + testSingleCase(chrono.vi, "từ ngày 5 tháng 8 đến ngày 10 tháng 8 năm 2012", REF, (r) => { + expect(r.start.get("day")).toBe(5); + expect(r.start.get("month")).toBe(8); + expect(r.start.get("year")).toBe(2012); + + expect(r.end).not.toBeNull(); + expect(r.end.get("day")).toBe(10); + expect(r.end.get("month")).toBe(8); + expect(r.end.get("year")).toBe(2012); + }); +}); + +test("Test - em dash separator (–)", () => { + testSingleCase(chrono.vi, "ngày 1 tháng 4 – ngày 30 tháng 4 năm 2000", REF, (r) => { + expect(r.start.get("day")).toBe(1); + expect(r.start.get("month")).toBe(4); + + expect(r.end).not.toBeNull(); + expect(r.end.get("day")).toBe(30); + expect(r.end.get("month")).toBe(4); + expect(r.end.get("year")).toBe(2000); + }); +}); + +test("Test - hyphen separator (-)", () => { + testSingleCase(chrono.vi, "ngày 3 tháng 9 - ngày 5 tháng 9 năm 1945", REF, (r) => { + expect(r.start.get("day")).toBe(3); + expect(r.start.get("month")).toBe(9); + expect(r.start.get("year")).toBe(1945); + + expect(r.end).not.toBeNull(); + expect(r.end.get("day")).toBe(5); + expect(r.end.get("month")).toBe(9); + expect(r.end.get("year")).toBe(1945); + }); +}); + +test("Test - tới connector", () => { + testSingleCase(chrono.vi, "tháng 3 tới tháng 5 năm 1975", REF, (r) => { + expect(r.start.get("month")).toBe(3); + + expect(r.end).not.toBeNull(); + expect(r.end.get("month")).toBe(5); + expect(r.end.get("year")).toBe(1975); + }); +}); + +test("Test - start is before end", () => { + testSingleCase(chrono.vi, "ngày 1 tháng 1 đến ngày 31 tháng 12 năm 2020", REF, (r) => { + expect(r.end).not.toBeNull(); + const startDate = r.start.date(); + const endDate = r.end.date(); + expect(startDate.getTime()).toBeLessThan(endDate.getTime()); + }); +}); diff --git a/test/vi/vi_forward_date.test.ts b/test/vi/vi_forward_date.test.ts new file mode 100644 index 00000000..81303784 --- /dev/null +++ b/test/vi/vi_forward_date.test.ts @@ -0,0 +1,45 @@ +import * as chrono from "../../src/"; +import { testSingleCase } from "../test_util"; + +test("Test - forwardDate: time-only rolls to next day when ref is past", () => { + // "7 gi\u1edd s\u00e1ng" = 7:00 AM; ref is 8:00 AM same day \u2192 next day + testSingleCase(chrono.vi, "7 gi\u1edd s\u00e1ng", new Date(2012, 7, 10, 8, 0), { forwardDate: true }, (r) => { + expect(r.start.get("day")).toBe(11); + expect(r.start.get("hour")).toBe(7); + }); +}); + +test("Test - forwardDate: weekday rolls to next occurrence", () => { + // REF = Tuesday Aug 14, 2012; "th\u1ee9 hai" (Monday) last was Aug 13 \u2192 next Monday = Aug 20 + testSingleCase(chrono.vi, "th\u1ee9 hai", new Date(2012, 7, 14, 12), { forwardDate: true }, (r) => { + expect(r.start.get("weekday")).toBe(1); + expect(r.start.get("day")).toBe(20); + expect(r.start.get("month")).toBe(8); + }); +}); + +test("Test - forwardDate: slash date without year rolls to next year", () => { + // "15/3" = March 15; ref is Aug 10 (past March) \u2192 year 2013 + testSingleCase(chrono.vi, "15/3", new Date(2012, 7, 10, 12), { forwardDate: true }, (r) => { + expect(r.start.get("day")).toBe(15); + expect(r.start.get("month")).toBe(3); + expect(r.start.get("year")).toBe(2013); + }); +}); + +test("Test - forwardDate: same weekday as ref stays on same day", () => { + // REF = Thursday Aug 9, 2012; "thứ năm" (Thursday) = same day → stays on Aug 9 (not past) + testSingleCase(chrono.vi, "th\u1ee9 n\u0103m", new Date(2012, 7, 9, 12), { forwardDate: true }, (r) => { + expect(r.start.get("weekday")).toBe(4); + expect(r.start.get("day")).toBe(9); + expect(r.start.get("month")).toBe(8); + }); +}); + +test("Test - forwardDate: month without year rolls to next year", () => { + // "th\u00e1ng 3" = March; ref is Aug 10 \u2192 next March = 2013 + testSingleCase(chrono.vi, "th\u00e1ng 3", new Date(2012, 7, 10, 12), { forwardDate: true }, (r) => { + expect(r.start.get("month")).toBe(3); + expect(r.start.get("year")).toBe(2013); + }); +}); diff --git a/test/vi/vi_month_year.test.ts b/test/vi/vi_month_year.test.ts new file mode 100644 index 00000000..00faa333 --- /dev/null +++ b/test/vi/vi_month_year.test.ts @@ -0,0 +1,40 @@ +import * as chrono from "../../src/"; +import { testSingleCase, testUnexpectedResult } from "../test_util"; + +const REF = new Date(2012, 7, 10, 12); // 2012-08-10 + +test("Test - tháng M năm YYYY", () => { + testSingleCase(chrono.vi, "tháng 4 năm 1975", REF, (r) => { + expect(r.text).toBe("tháng 4 năm 1975"); + expect(r.start.get("month")).toBe(4); + expect(r.start.get("year")).toBe(1975); + expect(r.start.isCertain("month")).toBe(true); + expect(r.start.isCertain("year")).toBe(true); + expect(r.start.isCertain("day")).toBe(false); // day is implied + }); + + testSingleCase(chrono.vi, "tháng 1 năm 1863", REF, (r) => { + expect(r.start.get("month")).toBe(1); + expect(r.start.get("year")).toBe(1863); + }); +}); + +test("Test - tháng M/YYYY (slash separator)", () => { + testSingleCase(chrono.vi, "tháng 3/1975", REF, (r) => { + expect(r.start.get("month")).toBe(3); + expect(r.start.get("year")).toBe(1975); + }); +}); + +test("Test - tháng M only (implies current year)", () => { + testSingleCase(chrono.vi, "tháng 3", REF, (r) => { + expect(r.start.get("month")).toBe(3); + expect(r.start.isCertain("year")).toBe(false); // year implied from reference + expect(r.start.get("year")).toBe(2012); + }); +}); + +test("Test - negative: month > 12 rejected", () => { + testUnexpectedResult(chrono.vi, "tháng 13", REF); + testUnexpectedResult(chrono.vi, "tháng 0", REF); +}); diff --git a/test/vi/vi_negative_cases.test.ts b/test/vi/vi_negative_cases.test.ts new file mode 100644 index 00000000..e9cb6218 --- /dev/null +++ b/test/vi/vi_negative_cases.test.ts @@ -0,0 +1,60 @@ +import * as chrono from "../../src/"; +import { testUnexpectedResult } from "../test_util"; + +const REF = new Date(2012, 7, 10, 12); // 2012-08-10 + +test("Test - invalid day rejected", () => { + testUnexpectedResult(chrono.vi, "ngày 0 tháng 4 năm 2000", REF); + // "ngày 32 tháng 4" — VIStandardParser skips "ngày 3" and sub-matches "2 tháng 4" + // (day bounds are not pre-validated in the regex). This is known parser behaviour. +}); + +test("Test - invalid month rejected", () => { + testUnexpectedResult(chrono.vi, "tháng 0", REF); + testUnexpectedResult(chrono.vi, "tháng 13", REF); + testUnexpectedResult(chrono.vi, "ngày 1 tháng 13", REF); +}); + +test("Test - invalid slash date rejected", () => { + testUnexpectedResult(chrono.vi, "32/13/2020", REF); +}); + +test("Test - bare 4-digit number without năm prefix is not a year", () => { + testUnexpectedResult(chrono.vi, "Có 1975 người tham gia.", REF); +}); + +test("Test - phone number not parsed as date", () => { + testUnexpectedResult(chrono.vi, "0912345678", REF); +}); + +test("Test - bare numbers not parsed as dates", () => { + testUnexpectedResult(chrono.vi, "3", REF); + testUnexpectedResult(chrono.vi, "11", REF); + testUnexpectedResult(chrono.vi, "0.5", REF); + testUnexpectedResult(chrono.vi, "35.49", REF); + testUnexpectedResult(chrono.vi, "12.53%", REF); +}); + +test("Test - currency and measurement not parsed as dates", () => { + testUnexpectedResult(chrono.vi, "$1,194.09", REF); + testUnexpectedResult(chrono.vi, "at 6.5 kilograms", REF); +}); + +test("Test - version numbers not parsed as dates", () => { + testUnexpectedResult(chrono.vi, "1.1.3", REF); + testUnexpectedResult(chrono.vi, "1.10.30", REF); +}); + +test("Test - hyphenated number ranges not parsed as dates", () => { + testUnexpectedResult(chrono.vi, "1-2", REF); + testUnexpectedResult(chrono.vi, "1-2-3", REF); +}); + +test("Test - URL-encoded strings not parsed as dates", () => { + testUnexpectedResult(chrono.vi, "%e7%b7%8a", REF); +}); + +test("Test - impossible minute rejected", () => { + testUnexpectedResult(chrono.vi, "7 giờ 61 phút", REF); + testUnexpectedResult(chrono.vi, "7 giờ 99 phút", REF); +}); diff --git a/test/vi/vi_slash.test.ts b/test/vi/vi_slash.test.ts new file mode 100644 index 00000000..209eec93 --- /dev/null +++ b/test/vi/vi_slash.test.ts @@ -0,0 +1,36 @@ +import * as chrono from "../../src/"; +import { testSingleCase } from "../test_util"; + +const REF = new Date(2012, 7, 10, 12); + +test("Test - DD/MM/YYYY (little-endian)", () => { + testSingleCase(chrono.vi, "Ng\u00e0y 30/04/1975.", REF, (r) => { + expect(r.index).toBe(5); + expect(r.start.get("day")).toBe(30); + expect(r.start.get("month")).toBe(4); + expect(r.start.get("year")).toBe(1975); + }); + + testSingleCase(chrono.vi, "H\u1ed9i ngh\u1ecb 01/01/1954", REF, (r) => { + expect(r.index).toBe(9); + expect(r.start.get("day")).toBe(1); + expect(r.start.get("month")).toBe(1); + expect(r.start.get("year")).toBe(1954); + }); +}); + +test("Test - D/M/YYYY (no zero padding)", () => { + testSingleCase(chrono.vi, "3/5/1968", REF, (r) => { + expect(r.start.get("day")).toBe(3); + expect(r.start.get("month")).toBe(5); + expect(r.start.get("year")).toBe(1968); + }); +}); + +test("Test - ISO 2024-03-15 also parses", () => { + testSingleCase(chrono.vi, "Ng\u00e0y 2024-03-15 l\u00e0 quan tr\u1ecdng.", REF, (r) => { + expect(r.start.get("year")).toBe(2024); + expect(r.start.get("month")).toBe(3); + expect(r.start.get("day")).toBe(15); + }); +}); diff --git a/test/vi/vi_standard.test.ts b/test/vi/vi_standard.test.ts new file mode 100644 index 00000000..0f33b647 --- /dev/null +++ b/test/vi/vi_standard.test.ts @@ -0,0 +1,86 @@ +import * as chrono from "../../src/"; +import { testSingleCase, testUnexpectedResult } from "../test_util"; + +const REF = new Date(2012, 7, 10, 12, 0, 0); // 2012-08-10 + +test("Test - ng\u00e0y D th\u00e1ng M n\u0103m YYYY (full with ng\u00e0y prefix)", () => { + testSingleCase( + chrono.vi, + "Ng\u00e0y 30 th\u00e1ng 4 n\u0103m 1975 l\u00e0 ng\u00e0y gi\u1ea3i ph\u00f3ng.", + REF, + (r) => { + expect(r.start.get("year")).toBe(1975); + expect(r.start.get("month")).toBe(4); + expect(r.start.get("day")).toBe(30); + expect(r.start.isCertain("year")).toBe(true); + expect(r.start.isCertain("month")).toBe(true); + expect(r.start.isCertain("day")).toBe(true); + } + ); + + testSingleCase( + chrono.vi, + "Hi\u1ec7p \u0111\u1ecbnh \u0111\u01b0\u1ee3c k\u00fd ng\u00e0y 27 th\u00e1ng 1 n\u0103m 1973.", + REF, + (r) => { + expect(r.text).toBe("ng\u00e0y 27 th\u00e1ng 1 n\u0103m 1973"); + expect(r.start.get("year")).toBe(1973); + expect(r.start.get("month")).toBe(1); + expect(r.start.get("day")).toBe(27); + } + ); + + testSingleCase(chrono.vi, "ng\u00e0y 2 th\u00e1ng 9 n\u0103m 1945", REF, (r) => { + expect(r.start.get("year")).toBe(1945); + expect(r.start.get("month")).toBe(9); + expect(r.start.get("day")).toBe(2); + }); +}); + +test("Test - D th\u00e1ng M n\u0103m YYYY (no ng\u00e0y prefix)", () => { + testSingleCase( + chrono.vi, + "7 th\u00e1ng 5 n\u0103m 1954 l\u00e0 ng\u00e0y ch\u1ea5m d\u1ee9t tr\u1eadn \u0110i\u1ec7n Bi\u00ean Ph\u1ee7.", + REF, + (r) => { + expect(r.start.get("day")).toBe(7); + expect(r.start.get("month")).toBe(5); + expect(r.start.get("year")).toBe(1954); + } + ); + + testSingleCase(chrono.vi, "1 th\u00e1ng 1 n\u0103m 1863", REF, (r) => { + expect(r.start.get("day")).toBe(1); + expect(r.start.get("month")).toBe(1); + expect(r.start.get("year")).toBe(1863); + }); +}); + +test("Test - Date without year implies closest year", () => { + testSingleCase(chrono.vi, "ng\u00e0y 15 th\u00e1ng 3", REF, (r) => { + expect(r.start.get("day")).toBe(15); + expect(r.start.get("month")).toBe(3); + expect(r.start.isCertain("year")).toBe(false); + expect(r.start.isCertain("day")).toBe(true); + expect(r.start.isCertain("month")).toBe(true); + }); +}); + +test("Test - index position is correct", () => { + testSingleCase( + chrono.vi, + "S\u1ef1 ki\u1ec7n ng\u00e0y 30 th\u00e1ng 4 n\u0103m 1975 quan tr\u1ecdng.", + REF, + (r) => { + expect(r.index).toBe(8); + expect(r.text).toBe("ng\u00e0y 30 th\u00e1ng 4 n\u0103m 1975"); + expect(r.start.get("year")).toBe(1975); + } + ); +}); + +test("Test - negative: invalid month rejected", () => { + // tháng 13 is rejected; without a year, no other parser fires + testUnexpectedResult(chrono.vi, "ngày 1 tháng 13", REF); + // "ngày 32 tháng 4" partially matches as "2 tháng 4" — day bounds enforced by VIStandardParser (day > 31) +}); diff --git a/test/vi/vi_strict.test.ts b/test/vi/vi_strict.test.ts new file mode 100644 index 00000000..6eff6ac6 --- /dev/null +++ b/test/vi/vi_strict.test.ts @@ -0,0 +1,67 @@ +import * as chrono from "../../src/"; +import { testSingleCase, testUnexpectedResult } from "../test_util"; + +const REF = new Date(2012, 7, 10, 12); // 2012-08-10 + +test("Test - Strict rejects casual date expressions", () => { + testUnexpectedResult(chrono.vi.strict, "h\u00f4m nay", REF); + testUnexpectedResult(chrono.vi.strict, "h\u00f4m qua", REF); + testUnexpectedResult(chrono.vi.strict, "ng\u00e0y mai", REF); + testUnexpectedResult(chrono.vi.strict, "ng\u00e0y kia", REF); +}); + +test("Test - Strict rejects casual time-of-day expressions", () => { + testUnexpectedResult(chrono.vi.strict, "bu\u1ed5i s\u00e1ng", REF); + testUnexpectedResult(chrono.vi.strict, "bu\u1ed5i tr\u01b0a", REF); + testUnexpectedResult(chrono.vi.strict, "bu\u1ed5i chi\u1ec1u", REF); + testUnexpectedResult(chrono.vi.strict, "bu\u1ed5i t\u1ed1i", REF); +}); + +test("Test - Strict rejects casual relative expressions", () => { + testUnexpectedResult(chrono.vi.strict, "tu\u1ea7n n\u00e0y", REF); + testUnexpectedResult(chrono.vi.strict, "th\u00e1ng tr\u01b0\u1edbc", REF); + testUnexpectedResult(chrono.vi.strict, "n\u0103m sau", REF); +}); + +test("Test - Strict rejects weekday-only expressions", () => { + testUnexpectedResult(chrono.vi.strict, "th\u1ee9 hai", REF); + testUnexpectedResult(chrono.vi.strict, "ch\u1ee7 nh\u1eadt", REF); +}); + +test("Test - Strict accepts standard full dates and times", () => { + testSingleCase(chrono.vi.strict, "ng\u00e0y 30 th\u00e1ng 4 n\u0103m 1975", REF, (r) => { + expect(r.start.get("year")).toBe(1975); + expect(r.start.get("month")).toBe(4); + expect(r.start.get("day")).toBe(30); + }); + + testSingleCase(chrono.vi.strict, "l\u00fac 7 gi\u1edd 30 ph\u00fat", REF, (r) => { + expect(r.start.get("hour")).toBe(7); + expect(r.start.get("minute")).toBe(30); + }); +}); + +test("Test - Strict accepts slash dates", () => { + testSingleCase(chrono.vi.strict, "30/4/1975", REF, (r) => { + expect(r.start.get("day")).toBe(30); + expect(r.start.get("month")).toBe(4); + expect(r.start.get("year")).toBe(1975); + }); + + testSingleCase(chrono.vi.strict, "15/3", REF, (r) => { + expect(r.start.get("day")).toBe(15); + expect(r.start.get("month")).toBe(3); + }); +}); + +test("Test - Strict accepts explicit time unit expressions", () => { + testSingleCase(chrono.vi.strict, "3 ng\u00e0y tr\u01b0\u1edbc", REF, (r) => { + expect(r.start.get("day")).toBe(7); + expect(r.start.get("month")).toBe(8); + }); + + testSingleCase(chrono.vi.strict, "2 tu\u1ea7n sau", REF, (r) => { + expect(r.start.get("day")).toBe(24); + expect(r.start.get("month")).toBe(8); + }); +}); diff --git a/test/vi/vi_time_exp.test.ts b/test/vi/vi_time_exp.test.ts new file mode 100644 index 00000000..e83d2e44 --- /dev/null +++ b/test/vi/vi_time_exp.test.ts @@ -0,0 +1,99 @@ +import * as chrono from "../../src/"; +import { testSingleCase } from "../test_util"; + +const REF = new Date(2012, 7, 10, 12); + +test("Test - X gi\u1edd", () => { + testSingleCase(chrono.vi, "Cu\u1ed9c h\u1ecdn l\u00fac 7 gi\u1edd.", REF, (r) => { + expect(r.index).toBe(9); + expect(r.text).toBe("l\u00fac 7 gi\u1edd"); + expect(r.start.get("hour")).toBe(7); + expect(r.start.get("minute")).toBe(0); + }); + + testSingleCase(chrono.vi, "7 gi\u1edd s\u00e1ng", REF, (r) => { + expect(r.start.get("hour")).toBe(7); + expect(r.start.get("meridiem")).toBe(0); // AM + }); + + testSingleCase(chrono.vi, "7 gi\u1edd t\u1ed1i", REF, (r) => { + expect(r.start.get("hour")).toBe(19); + }); +}); + +test("Test - X gi\u1edd Y ph\u00fat", () => { + testSingleCase(chrono.vi, "l\u00fac 7 gi\u1edd 30 ph\u00fat", REF, (r) => { + expect(r.start.get("hour")).toBe(7); + expect(r.start.get("minute")).toBe(30); + }); + + testSingleCase(chrono.vi, "v\u00e0o 15 gi\u1edd 45 ph\u00fat", REF, (r) => { + expect(r.start.get("hour")).toBe(15); + expect(r.start.get("minute")).toBe(45); + }); +}); + +test("Test - colon format HH:MM", () => { + testSingleCase(chrono.vi, "H\u1ecdn l\u00fac 15:30.", REF, (r) => { + expect(r.start.get("hour")).toBe(15); + expect(r.start.get("minute")).toBe(30); + }); +}); + +test("Test - date + time combined", () => { + testSingleCase(chrono.vi, "ng\u00e0y 30 th\u00e1ng 4 n\u0103m 1975 l\u00fac 11 gi\u1edd", REF, (r) => { + expect(r.start.get("day")).toBe(30); + expect(r.start.get("month")).toBe(4); + expect(r.start.get("year")).toBe(1975); + expect(r.start.get("hour")).toBe(11); + }); +}); + +test("Test - meridiem: s\u00e1ng/chi\u1ec1u/t\u1ed1i/\u0111\u00eam", () => { + testSingleCase(chrono.vi, "9 gi\u1edd s\u00e1ng", REF, (r) => { + expect(r.start.get("hour")).toBe(9); + expect(r.start.get("meridiem")).toBe(0); // AM + }); + testSingleCase(chrono.vi, "3 gi\u1edd chi\u1ec1u", REF, (r) => { + expect(r.start.get("hour")).toBe(15); + }); + testSingleCase(chrono.vi, "10 gi\u1edd \u0111\u00eam", REF, (r) => { + expect(r.start.get("hour")).toBe(22); + }); +}); + +test("Test - trưa meridiem: X giờ trưa → PM (hour + 12)", () => { + // trưa = noon/midday — times expressed with trưa are PM + testSingleCase(chrono.vi, "1 giờ trưa", REF, (r) => { + expect(r.start.get("hour")).toBe(13); + expect(r.start.get("meridiem")).toBe(1); // PM + }); + testSingleCase(chrono.vi, "11 giờ trưa", REF, (r) => { + expect(r.start.get("hour")).toBe(11); + expect(r.start.get("meridiem")).toBe(0); // AM — 11 giờ trưa = approaching noon + }); + // 12 giờ trưa = noon, no offset applied (12 < 12 is false) + testSingleCase(chrono.vi, "12 giờ trưa", REF, (r) => { + expect(r.start.get("hour")).toBe(12); + expect(r.start.get("meridiem")).toBe(1); // PM + }); +}); + +test("Test - 12 giờ sáng = midnight (00:00)", () => { + testSingleCase(chrono.vi, "12 giờ sáng", REF, (r) => { + expect(r.start.get("hour")).toBe(0); + expect(r.start.get("meridiem")).toBe(0); // AM + }); +}); + +test("Test - isCertain: hour and meridiem certainty", () => { + testSingleCase(chrono.vi, "lúc 7 giờ", REF, (r) => { + expect(r.start.isCertain("hour")).toBe(true); + expect(r.start.isCertain("meridiem")).toBe(false); + }); + + testSingleCase(chrono.vi, "7 giờ sáng", REF, (r) => { + expect(r.start.isCertain("hour")).toBe(true); + expect(r.start.isCertain("meridiem")).toBe(true); + }); +}); diff --git a/test/vi/vi_time_units_ago.test.ts b/test/vi/vi_time_units_ago.test.ts new file mode 100644 index 00000000..78dda59b --- /dev/null +++ b/test/vi/vi_time_units_ago.test.ts @@ -0,0 +1,56 @@ +import * as chrono from "../../src/"; +import { testSingleCase } from "../test_util"; + +test("Test - N ng\u00e0y tr\u01b0\u1edbc", () => { + testSingleCase(chrono.vi, "S\u1ef1 ki\u1ec7n 3 ng\u00e0y tr\u01b0\u1edbc.", new Date(2012, 7, 10, 12), (r) => { + expect(r.index).toBe(8); + expect(r.text).toBe("3 ng\u00e0y tr\u01b0\u1edbc"); + expect(r.start.get("year")).toBe(2012); + expect(r.start.get("month")).toBe(8); + expect(r.start.get("day")).toBe(7); + }); +}); + +test("Test - N tu\u1ea7n tr\u01b0\u1edbc", () => { + testSingleCase(chrono.vi, "2 tu\u1ea7n tr\u01b0\u1edbc", new Date(2012, 7, 10, 12), (r) => { + expect(r.start.get("day")).toBe(27); // Aug 27 - 14 = July 27 + expect(r.start.get("month")).toBe(7); + }); +}); + +test("Test - N th\u00e1ng tr\u01b0\u1edbc", () => { + testSingleCase(chrono.vi, "3 th\u00e1ng tr\u01b0\u1edbc", new Date(2012, 7, 10, 12), (r) => { + expect(r.start.get("month")).toBe(5); + expect(r.start.get("year")).toBe(2012); + }); +}); + +test("Test - N n\u0103m tr\u01b0\u1edbc", () => { + testSingleCase(chrono.vi, "5 n\u0103m tr\u01b0\u1edbc", new Date(2012, 7, 10, 12), (r) => { + expect(r.start.get("year")).toBe(2007); + }); +}); + +test("Test - qua variant (1 tháng qua)", () => { + testSingleCase(chrono.vi, "1 tháng qua", new Date(2012, 7, 10, 12), (r) => { + expect(r.start.get("month")).toBe(7); + expect(r.start.get("year")).toBe(2012); + }); +}); + +test("Test - number-word durations (hai/ba/một)", () => { + testSingleCase(chrono.vi, "hai tuần trước", new Date(2012, 7, 10, 12), (r) => { + expect(r.start.get("day")).toBe(27); + expect(r.start.get("month")).toBe(7); + }); + + testSingleCase(chrono.vi, "ba ngày trước", new Date(2012, 7, 10, 12), (r) => { + expect(r.start.get("day")).toBe(7); + expect(r.start.get("month")).toBe(8); + }); + + testSingleCase(chrono.vi, "một tháng qua", new Date(2012, 7, 10, 12), (r) => { + expect(r.start.get("month")).toBe(7); + expect(r.start.get("year")).toBe(2012); + }); +}); diff --git a/test/vi/vi_time_units_casual_relative.test.ts b/test/vi/vi_time_units_casual_relative.test.ts new file mode 100644 index 00000000..ff5519c2 --- /dev/null +++ b/test/vi/vi_time_units_casual_relative.test.ts @@ -0,0 +1,56 @@ +import * as chrono from "../../src/"; +import { testSingleCase } from "../test_util"; + +const REF = new Date(2012, 7, 10, 12); // 2012-08-10 noon + +test("Test - hôm qua lúc 7 giờ (morning reference = AM context)", () => { + // Reference at 6am => implicit meridiem AM => 7 giờ = 7:00 AM + testSingleCase(chrono.vi, "hôm qua lúc 7 giờ", new Date(2012, 7, 10, 6), (r) => { + expect(r.start.get("year")).toBe(2012); + expect(r.start.get("month")).toBe(8); + expect(r.start.get("day")).toBe(9); + expect(r.start.get("hour")).toBe(7); + }); +}); + +test("Test - ngày mai lúc 15 giờ", () => { + testSingleCase(chrono.vi, "ngày mai lúc 15 giờ", REF, (r) => { + expect(r.start.get("day")).toBe(11); + expect(r.start.get("hour")).toBe(15); + }); +}); + +test("Test - hôm nay buổi sáng", () => { + testSingleCase(chrono.vi, "hôm nay buổi sáng", REF, (r) => { + expect(r.start.get("day")).toBe(10); + expect(r.start.get("hour")).toBe(9); + }); +}); + +test("Test - bare unit: tháng trước (last month, no number)", () => { + testSingleCase(chrono.vi, "tháng trước", REF, (r) => { + expect(r.start.get("year")).toBe(2012); + expect(r.start.get("month")).toBe(7); // August - 1 = July + }); +}); + +test("Test - bare unit: năm sau (next year, no number)", () => { + testSingleCase(chrono.vi, "năm sau", REF, (r) => { + expect(r.start.get("year")).toBe(2013); + }); +}); + +test("Test - bare unit: tuần trước (last week, no number)", () => { + testSingleCase(chrono.vi, "tuần trước", REF, (r) => { + expect(r.start.get("year")).toBe(2012); + expect(r.start.get("month")).toBe(8); + expect(r.start.get("day")).toBe(3); // 10 - 7 = 3 + }); +}); + +test("Test - numbered form still works: 2 tuần trước", () => { + testSingleCase(chrono.vi, "2 tuần trước", REF, (r) => { + expect(r.start.get("day")).toBe(27); // Aug 10 - 14 days = Jul 27 + expect(r.start.get("month")).toBe(7); + }); +}); diff --git a/test/vi/vi_time_units_later.test.ts b/test/vi/vi_time_units_later.test.ts new file mode 100644 index 00000000..d0017a33 --- /dev/null +++ b/test/vi/vi_time_units_later.test.ts @@ -0,0 +1,43 @@ +import * as chrono from "../../src/"; +import { testSingleCase } from "../test_util"; + +test("Test - N ng\u00e0y sau", () => { + testSingleCase(chrono.vi, "S\u1ef1 ki\u1ec7n 3 ng\u00e0y sau.", new Date(2012, 7, 10, 12), (r) => { + expect(r.index).toBe(8); + expect(r.text).toBe("3 ng\u00e0y sau"); + expect(r.start.get("day")).toBe(13); + expect(r.start.get("month")).toBe(8); + }); +}); + +test("Test - N tu\u1ea7n n\u1eefa", () => { + testSingleCase(chrono.vi, "2 tu\u1ea7n n\u1eefa", new Date(2012, 7, 10, 12), (r) => { + expect(r.start.get("day")).toBe(24); + expect(r.start.get("month")).toBe(8); + }); +}); + +test("Test - N th\u00e1ng t\u1edbi", () => { + testSingleCase(chrono.vi, "3 th\u00e1ng t\u1edbi", new Date(2012, 7, 10, 12), (r) => { + expect(r.start.get("month")).toBe(11); + expect(r.start.get("year")).toBe(2012); + }); +}); + +test("Test - N n\u0103m sau", () => { + testSingleCase(chrono.vi, "10 n\u0103m sau", new Date(2012, 7, 10, 12), (r) => { + expect(r.start.get("year")).toBe(2022); + }); +}); + +test("Test - number-word durations (ba/hai)", () => { + testSingleCase(chrono.vi, "ba ngày sau", new Date(2012, 7, 10, 12), (r) => { + expect(r.start.get("day")).toBe(13); + expect(r.start.get("month")).toBe(8); + }); + + testSingleCase(chrono.vi, "hai tuần nữa", new Date(2012, 7, 10, 12), (r) => { + expect(r.start.get("day")).toBe(24); + expect(r.start.get("month")).toBe(8); + }); +}); diff --git a/test/vi/vi_time_units_within.test.ts b/test/vi/vi_time_units_within.test.ts new file mode 100644 index 00000000..1330a01d --- /dev/null +++ b/test/vi/vi_time_units_within.test.ts @@ -0,0 +1,24 @@ +import * as chrono from "../../src/"; +import { testSingleCase } from "../test_util"; + +test("Test - trong N ng\u00e0y", () => { + testSingleCase(chrono.vi, "trong 3 ng\u00e0y", new Date(2012, 7, 10, 12), (r) => { + expect(r.text).toBe("trong 3 ng\u00e0y"); + expect(r.start.get("day")).toBe(13); + expect(r.start.get("month")).toBe(8); + }); +}); + +test("Test - trong N tu\u1ea7n", () => { + testSingleCase(chrono.vi, "Ho\u00e0n th\u00e0nh trong 2 tu\u1ea7n.", new Date(2012, 7, 10, 12), (r) => { + expect(r.start.get("day")).toBe(24); + expect(r.start.get("month")).toBe(8); + }); +}); + +test("Test - trong v\u00f2ng N th\u00e1ng", () => { + testSingleCase(chrono.vi, "trong v\u00f2ng 3 th\u00e1ng", new Date(2012, 7, 10, 12), (r) => { + expect(r.start.get("month")).toBe(11); + expect(r.start.get("year")).toBe(2012); + }); +}); diff --git a/test/vi/vi_weekday.test.ts b/test/vi/vi_weekday.test.ts new file mode 100644 index 00000000..82e49927 --- /dev/null +++ b/test/vi/vi_weekday.test.ts @@ -0,0 +1,77 @@ +import * as chrono from "../../src/"; +import { testSingleCase } from "../test_util"; + +// REF: 2012-08-09 Thu +const REF = new Date(2012, 7, 9); + +test("Test - full weekday names", () => { + testSingleCase(chrono.vi, "H\u1ecdn v\u00e0o th\u1ee9 hai", REF, (r) => { + expect(r.index).toBe(8); + expect(r.start.get("weekday")).toBe(1); + }); + testSingleCase(chrono.vi, "th\u1ee9 ba", REF, (r) => { + expect(r.start.get("weekday")).toBe(2); + }); + testSingleCase(chrono.vi, "th\u1ee9 t\u01b0", REF, (r) => { + expect(r.start.get("weekday")).toBe(3); + }); + testSingleCase(chrono.vi, "th\u1ee9 n\u0103m", REF, (r) => { + expect(r.start.get("weekday")).toBe(4); + }); + testSingleCase(chrono.vi, "th\u1ee9 s\u00e1u", REF, (r) => { + expect(r.start.get("weekday")).toBe(5); + }); + testSingleCase(chrono.vi, "th\u1ee9 b\u1ea3y", REF, (r) => { + expect(r.start.get("weekday")).toBe(6); + }); + testSingleCase(chrono.vi, "ch\u1ee7 nh\u1eadt", REF, (r) => { + expect(r.start.get("weekday")).toBe(0); + }); +}); + +test("Test - abbreviations t2-t7 / cn", () => { + testSingleCase(chrono.vi, "H\u1ecdn t2", REF, (r) => { + expect(r.index).toBe(4); + expect(r.start.get("weekday")).toBe(1); + }); + testSingleCase(chrono.vi, "t7", REF, (r) => { + expect(r.start.get("weekday")).toBe(6); + }); + testSingleCase(chrono.vi, "cn", REF, (r) => { + expect(r.start.get("weekday")).toBe(0); + }); +}); + +test("Test - weekday implies a date", () => { + testSingleCase(chrono.vi, "th\u1ee9 hai t\u1edbi", REF, (r) => { + expect(r.start.get("weekday")).toBe(1); + expect(r.start.isCertain("day")).toBe(false); // implied, not certain + }); +}); + +test("Test - next weekday modifiers (tới/sau)", () => { + // REF is Thu 2012-08-09; next Monday = 2012-08-13 + testSingleCase(chrono.vi, "th\u1ee9 hai t\u1edbi", REF, (r) => { + expect(r.start.get("weekday")).toBe(1); + expect(r.start.get("day")).toBe(13); + }); + testSingleCase(chrono.vi, "th\u1ee9 hai sau", REF, (r) => { + expect(r.start.get("weekday")).toBe(1); + expect(r.start.get("day")).toBe(13); + }); +}); + +test("Test - last weekday modifier (qua)", () => { + // REF is Thu 2012-08-09; last Monday = 2012-08-06 + testSingleCase(chrono.vi, "th\u1ee9 hai qua", REF, (r) => { + expect(r.start.get("weekday")).toBe(1); + expect(r.start.get("day")).toBe(6); + }); +}); + +test("Test - 'sau khi' conjunction not parsed as weekday modifier", () => { + const results = chrono.vi.parse("thứ hai sau khi chiến tranh kết thúc", new Date(2012, 7, 10, 12)); + // Should parse 'thứ hai' (Monday) without consuming 'sau' from 'sau khi' + expect(results.length).toBe(1); + expect(results[0].text).toBe("thứ hai"); // 'sau' not included +}); diff --git a/test/vi/vi_year.test.ts b/test/vi/vi_year.test.ts new file mode 100644 index 00000000..6c6e85e1 --- /dev/null +++ b/test/vi/vi_year.test.ts @@ -0,0 +1,42 @@ +import * as chrono from "../../src/"; +import { testSingleCase, testUnexpectedResult } from "../test_util"; + +const REF = new Date(2012, 7, 10, 12); + +test("Test - n\u0103m YYYY", () => { + testSingleCase(chrono.vi, "Vi\u1ec7t Nam th\u1ed1ng nh\u1ea5t v\u00e0o n\u0103m 1976.", REF, (r) => { + expect(r.text).toBe("n\u0103m 1976"); + expect(r.start.get("year")).toBe(1976); + }); + + testSingleCase(chrono.vi, "C\u00e1ch m\u1ea1ng n\u0103m 1789.", REF, (r) => { + expect(r.start.get("year")).toBe(1789); + }); +}); + +test("Test - n\u0103m BC (TCN)", () => { + testSingleCase(chrono.vi, "N\u0103m 179 TCN, tri\u1ec1u \u0110i\u1ec7t b\u1ecb di\u1ec7t.", REF, (r) => { + expect(r.start.get("year")).toBe(-179); + }); + + testSingleCase(chrono.vi, "V\u0103n minh c\u00f3 t\u1eeb n\u0103m 3000 TCN.", REF, (r) => { + expect(r.start.get("year")).toBe(-3000); + }); +}); + +test("Test - 3-digit year", () => { + testSingleCase(chrono.vi, "n\u0103m 938 l\u00e0 n\u0103m \u0111\u1ed9c l\u1eadp.", REF, (r) => { + expect(r.start.get("year")).toBe(938); + }); +}); + +test("Test - year with month and day", () => { + testSingleCase(chrono.vi, "ng\u00e0y 2 th\u00e1ng 9 n\u0103m 1945", REF, (r) => { + expect(r.start.get("year")).toBe(1945); + expect(r.start.isCertain("year")).toBe(true); + }); +}); + +test("Test - negative: bare 4-digit number not a year", () => { + testUnexpectedResult(chrono.vi, "C\u00f3 1975 ng\u01b0\u1eddi tham gia.", REF); +});