diff --git a/docs/getting-started.md b/docs/getting-started.md index 97200237f..2ebc24f01 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -68,18 +68,18 @@ A few utils embed an official dataset and weigh far more than everything else co | Util | Dataset | Minified | Gzipped | | --- | --- | --- | --- | -| `getCid10` | CID-10 V2008 categories and subcategories plus the SIM `U07` codes, with the DATASUS descriptions | 721.5 KB | 113.5 KB | +| `getCid10` | CID-10 V2008 categories and subcategories plus the SIM `U07` codes, with the DATASUS descriptions | 721.6 KB | 113.5 KB | | `getCest` | CEST descriptions and segments (Convênio ICMS 142/18) | 102.3 KB | 24.1 KB | -| `getMunicipalitiesByAreaCode` · `getAreaCodeByMunicipalityCode` | 5571 IBGE municipalities, with the DDD of each (Anatel) | 23.6 - 90.3 KB | 5.6 - 33.0 KB | +| `getMunicipalitiesByAreaCode` · `getAreaCodeByMunicipalityCode` | 5571 IBGE municipalities, with the DDD of each (Anatel) | 23.6 - 90.4 KB | 5.6 - 33.0 KB | | `getCbo` | CBO 2002 occupation titles | 80.6 KB | 24.1 KB | -| `getMunicipalities` · `getMunicipalityByCode` · `getCodeByMunicipalityName` · `getMunicipality` | 5571 IBGE municipalities, with names and codes | 76.0 - 76.9 KB | 29.4 - 29.9 KB | +| `getMunicipalities` · `getMunicipalityByCode` · `getCodeByMunicipalityName` · `getMunicipality` | 5571 IBGE municipalities, with names and codes | 76.3 - 77.2 KB | 29.5 - 30.1 KB | | `getCnae` | CNAE-Subclasses 2.3 | 66.8 KB | 17.7 KB | | `getCities` | 5571 IBGE municipality names | 64.2 KB | 26.4 KB | | `getCfop` | CFOP operation descriptions | 56.9 KB | 5.1 KB | | `getNbs` | NBS 2.0 (Nomenclatura Brasileira de Serviços) descriptions | 51.1 KB | 11.3 KB | | `getClassTrib` | cClassTrib (IBS/CBS) names and descriptions | 50.0 KB | 9.0 KB | | `isValidNcm` | NCM (Nomenclatura Comum do Mercosul) codes | 29.6 KB | 9.0 KB | -| `getBanks` · `getBankByCode` · `getBankByIspb` | Banco Central STR participants (COMPE + ISPB) | 26.2 - 26.5 KB | 7.6 - 7.8 KB | +| `getBanks` · `getBankByCode` · `getBankByIspb` | Banco Central STR participants (COMPE + ISPB) | 26.2 - 26.6 KB | 7.6 - 7.9 KB | | `getServiceItem` | Service list of the Lei Complementar 116/2003 | 25.5 KB | 8.2 KB | | `getIsbnInfo` · `formatIsbn` | ISBN ranges of the International ISBN Agency (RangeMessage) | 15.1 - 15.3 KB | 5.4 - 5.5 KB | | `isValidCbo` | CBO 2002 occupation codes, without the titles | 6.7 KB | 1.7 KB | diff --git a/docs/pt-br/getting-started.md b/docs/pt-br/getting-started.md index f8b969d4b..d58f9899a 100644 --- a/docs/pt-br/getting-started.md +++ b/docs/pt-br/getting-started.md @@ -68,18 +68,18 @@ Alguns utilitários embutem uma base de dados oficial e pesam muito mais que tod | Utilitário | Base de dados | Minificado | Gzip | | --- | --- | --- | --- | -| `getCid10` | categorias e subcategorias da CID-10 V2008 mais os códigos `U07` do SIM, com as descrições do DATASUS | 721,5 KB | 113,5 KB | +| `getCid10` | categorias e subcategorias da CID-10 V2008 mais os códigos `U07` do SIM, com as descrições do DATASUS | 721,6 KB | 113,5 KB | | `getCest` | descrições e segmentos do CEST (Convênio ICMS 142/18) | 102,3 KB | 24,1 KB | -| `getMunicipalitiesByAreaCode` · `getAreaCodeByMunicipalityCode` | 5571 municípios do IBGE, com o DDD de cada um (Anatel) | 23,6 - 90,3 KB | 5,6 - 33,0 KB | +| `getMunicipalitiesByAreaCode` · `getAreaCodeByMunicipalityCode` | 5571 municípios do IBGE, com o DDD de cada um (Anatel) | 23,6 - 90,4 KB | 5,6 - 33,0 KB | | `getCbo` | títulos das ocupações da CBO 2002 | 80,6 KB | 24,1 KB | -| `getMunicipalities` · `getMunicipalityByCode` · `getCodeByMunicipalityName` · `getMunicipality` | 5571 municípios do IBGE, com nomes e códigos | 76,0 - 76,9 KB | 29,4 - 29,9 KB | +| `getMunicipalities` · `getMunicipalityByCode` · `getCodeByMunicipalityName` · `getMunicipality` | 5571 municípios do IBGE, com nomes e códigos | 76,3 - 77,2 KB | 29,5 - 30,1 KB | | `getCnae` | CNAE-Subclasses 2.3 | 66,8 KB | 17,7 KB | | `getCities` | nomes dos 5571 municípios do IBGE | 64,2 KB | 26,4 KB | | `getCfop` | descrições das operações do CFOP | 56,9 KB | 5,1 KB | | `getNbs` | descrições da NBS 2.0 (Nomenclatura Brasileira de Serviços) | 51,1 KB | 11,3 KB | | `getClassTrib` | nomes e descrições do cClassTrib (IBS/CBS) | 50,0 KB | 9,0 KB | | `isValidNcm` | códigos NCM (Nomenclatura Comum do Mercosul) | 29,6 KB | 9,0 KB | -| `getBanks` · `getBankByCode` · `getBankByIspb` | participantes do STR do Banco Central (COMPE + ISPB) | 26,2 - 26,5 KB | 7,6 - 7,8 KB | +| `getBanks` · `getBankByCode` · `getBankByIspb` | participantes do STR do Banco Central (COMPE + ISPB) | 26,2 - 26,6 KB | 7,6 - 7,9 KB | | `getServiceItem` | lista de serviços da Lei Complementar 116/2003 | 25,5 KB | 8,2 KB | | `getIsbnInfo` · `formatIsbn` | faixas do ISBN da Agência Internacional do ISBN (RangeMessage) | 15,1 - 15,3 KB | 5,4 - 5,5 KB | | `isValidCbo` | códigos das ocupações da CBO 2002, sem os títulos | 6,7 KB | 1,7 KB | diff --git a/src/_internals/crc16-ccitt/crc16-ccitt.test.ts b/src/_internals/crc16-ccitt/crc16-ccitt.test.ts index 8b21760c1..7262c34ea 100644 --- a/src/_internals/crc16-ccitt/crc16-ccitt.test.ts +++ b/src/_internals/crc16-ccitt/crc16-ccitt.test.ts @@ -1,11 +1,31 @@ import { describe, expect, test } from "../test/runtime"; import { crc16Ccitt } from "./crc16-ccitt"; +const reference = (value: string): string => { + let crc = 0xff_ff; + + for (const byte of new TextEncoder().encode(value)) { + crc ^= byte << 8; + + for (let bit = 0; bit < 8; bit++) { + crc = (crc & 0x80_00) === 0 ? (crc << 1) & 0xff_ff : ((crc << 1) ^ 0x10_21) & 0xff_ff; + } + } + + return crc.toString(16).toUpperCase().padStart(4, "0"); +}; + describe("crc16Ccitt", () => { test("should match the CRC-16/CCITT-FALSE check value", () => { expect(crc16Ccitt("123456789")).toBe("29B1"); }); + test("should checksum the UTF-8 bytes of a character from U+0080 up, not its code", () => { + expect(crc16Ccitt("\u0080")).toBe("FCB1"); + expect(crc16Ccitt("a\u0080b")).toBe("B44D"); + expect(crc16Ccitt("\u007F")).toBe("6E88"); + }); + test("should return the initial value for an empty string", () => { expect(crc16Ccitt("")).toBe("FFFF"); }); @@ -40,6 +60,12 @@ describe("crc16Ccitt", () => { ).toBe("AD38"); }); + test("should checksum the UTF-8 bytes of a value with non-ASCII characters", () => { + for (const value of ["é", "abc é", "日本語", "a😀b", "\uD800", "São Paulo"]) { + expect(crc16Ccitt(value)).toBe(reference(value)); + } + }); + test("should change when the payload changes", () => { expect(crc16Ccitt("A")).not.toBe(crc16Ccitt("B")); }); diff --git a/src/_internals/crc16-ccitt/crc16-ccitt.ts b/src/_internals/crc16-ccitt/crc16-ccitt.ts index c6d490851..f398b071a 100644 --- a/src/_internals/crc16-ccitt/crc16-ccitt.ts +++ b/src/_internals/crc16-ccitt/crc16-ccitt.ts @@ -6,6 +6,38 @@ const MASK = 0xff_ff; const HEX_LENGTH = 4; +const BYTE_VALUES = 256; + +const ASCII_LIMIT = 0x80; + +let table: Uint16Array | undefined; + +const buildTable = (): Uint16Array => + Uint16Array.from({ length: BYTE_VALUES }, (_, byte) => { + let crc = byte << 8; + + for (let bit = 0; bit < 8; bit++) { + crc = (crc & 0x80_00) === 0 ? (crc << 1) & MASK : ((crc << 1) ^ POLYNOMIAL) & MASK; + } + + return crc; + }); + +const update = (crc: number, byte: number, entries: Uint16Array): number => + ((crc << 8) & MASK) ^ entries[(crc >> 8) ^ byte]; + +const toHex = (crc: number): string => crc.toString(16).toUpperCase().padStart(HEX_LENGTH, "0"); + +const checksumUtf8 = (value: string, entries: Uint16Array): string => { + let crc = INITIAL_VALUE; + + for (const byte of new TextEncoder().encode(value)) { + crc = update(crc, byte, entries); + } + + return toHex(crc); +}; + /** * Calculates the CRC-16/CCITT-FALSE checksum of a string and returns it as four uppercase * hexadecimal digits. @@ -24,17 +56,18 @@ const HEX_LENGTH = 4; * ``` */ export const crc16Ccitt = (value: string): string => { - const bytes = new TextEncoder().encode(value); + table ??= buildTable(); let crc = INITIAL_VALUE; - for (const byte of bytes) { - crc ^= byte << 8; + for (let index = 0; index < value.length; index++) { + const code = value.charCodeAt(index); - for (let bit = 0; bit < 8; bit++) { - crc = (crc & 0x80_00) === 0 ? (crc << 1) & MASK : ((crc << 1) ^ POLYNOMIAL) & MASK; - } + // Stryker disable next-line ConditionalExpression: leaving for the UTF-8 pass at the first character gives the checksum the ASCII pass gives for a string of ASCII characters. + if (code >= ASCII_LIMIT) return checksumUtf8(value, table); + + crc = update(crc, code, table); } - return crc.toString(16).toUpperCase().padStart(HEX_LENGTH, "0"); + return toHex(crc); }; diff --git a/src/_internals/each-local-day/each-local-day.ts b/src/_internals/each-local-day/each-local-day.ts index b352b8f74..c868db140 100644 --- a/src/_internals/each-local-day/each-local-day.ts +++ b/src/_internals/each-local-day/each-local-day.ts @@ -16,12 +16,14 @@ const NOON = 12; * * Both ends are local calendar days written as the number `Date.UTC(year, month, day)` returns * for them, so the walk is plain integer arithmetic on days: it always advances, it always stops - * after `Math.abs(until - from)` days, and no `Date` is ever mutated. That is what makes it safe - * in every time zone. A walk driven by `date.setDate(date.getDate() + 1)` is not: when the - * neighbouring local day does not exist (`Pacific/Apia` and `Pacific/Fakaofo` skipped 30 December 2011, - * `Pacific/Kiritimati` and `Pacific/Enderbury` 31 December 1994, `Pacific/Kwajalein` 21 August 1993, all of them crossing - * the date line) the runtime re-normalizes onto the same local day, the walk stops advancing and - * the loop never ends. + * after `Math.abs(until - from)` days, and no `Date` a caller sees is ever mutated. The walk + * reuses one private scratch `Date`, read only through its UTC getters, and hands out a new + * `Date` for each day. That is what makes it safe in every time zone. A walk driven by + * `date.setDate(date.getDate() + 1)` is not: when the neighbouring local day does not exist + * (`Pacific/Apia` and `Pacific/Fakaofo` skipped 30 December 2011, `Pacific/Kiritimati` and + * `Pacific/Enderbury` 31 December 1994, `Pacific/Kwajalein` 21 August 1993, all of them + * crossing the date line) the runtime re-normalizes onto the same local day, the walk stops + * advancing and the loop never ends. * * Each day is yielded at **noon**, not at midnight, because noon is a time of day every existing * local calendar day has: a transition that moves the clock forward (Brazilian summer time always @@ -50,8 +52,10 @@ export function* eachLocalDay({ from, until }: EachLocalDayParams): Generator { + test("should find every bank of the table by its code and by its ISPB", () => { + for (const bank of readBanks()) { + expect(findBank("code", bank.code)).toBe( + readBanks().find((entry) => entry.code === bank.code), + ); + expect(findBank("ispb", bank.ispb)).toBe( + readBanks().find((entry) => entry.ispb === bank.ispb), + ); + } + }); + + test("should return undefined for a value no bank has", () => { + expect(findBank("code", "000")).toBeUndefined(); + expect(findBank("ispb", "abc")).toBeUndefined(); + }); + + test("should not match a prototype chain key", () => { + expect(findBank("code", "constructor")).toBeUndefined(); + expect(findBank("ispb", "__proto__")).toBeUndefined(); + }); +}); diff --git a/src/_internals/find-bank/find-bank.ts b/src/_internals/find-bank/find-bank.ts new file mode 100644 index 000000000..c385b930a --- /dev/null +++ b/src/_internals/find-bank/find-bank.ts @@ -0,0 +1,31 @@ +import { type Bank } from "../constants/banks"; +import { readBanks } from "../read-banks/read-banks"; + +type BankKey = "code" | "ispb"; + +const indexes: Partial>> = {}; + +const buildBankIndex = (key: BankKey): Map => { + const index = new Map(); + const banks = readBanks(); + + for (let position = banks.length - 1; position >= 0; position--) { + index.set(banks[position][key], banks[position]); + } + + return index; +}; + +/** + * Finds the first bank whose `code` or `ispb` equals a value, through an index built on the + * first lookup of each key. + * + * @param {BankKey} key - The bank field to match: `"code"` or `"ispb"`. + * @param {string} value - The zero-padded value to look for. + * @returns {Bank|undefined} The bank of the table itself, not a copy, or `undefined` when none matches. + */ +export const findBank = (key: BankKey, value: string): Bank | undefined => { + indexes[key] ??= buildBankIndex(key); + + return indexes[key].get(value); +}; diff --git a/src/_internals/is-business-day-under-rules/is-business-day-under-rules.test.ts b/src/_internals/is-business-day-under-rules/is-business-day-under-rules.test.ts new file mode 100644 index 000000000..9d492411e --- /dev/null +++ b/src/_internals/is-business-day-under-rules/is-business-day-under-rules.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from "../test/runtime"; +import { isBusinessDayUnderRules } from "./is-business-day-under-rules"; + +const WEEKDAYS = { stateCode: undefined, includeOptional: true, includeSaturday: false }; + +describe("isBusinessDayUnderRules", () => { + test("should accept an ordinary weekday", () => { + expect(isBusinessDayUnderRules(new Date(2024, 0, 2), WEEKDAYS)).toBe(true); + }); + + test("should reject a national holiday", () => { + expect(isBusinessDayUnderRules(new Date(2024, 0, 1), WEEKDAYS)).toBe(false); + }); + + test("should reject Sunday, and Saturday unless it is included", () => { + expect( + isBusinessDayUnderRules(new Date(2024, 0, 7), { ...WEEKDAYS, includeSaturday: true }), + ).toBe(false); + expect(isBusinessDayUnderRules(new Date(2024, 0, 6), WEEKDAYS)).toBe(false); + expect( + isBusinessDayUnderRules(new Date(2024, 0, 6), { ...WEEKDAYS, includeSaturday: true }), + ).toBe(true); + }); + + test("should keep a holiday that falls on a Saturday a non-business day", () => { + expect( + isBusinessDayUnderRules(new Date(2024, 8, 7), { ...WEEKDAYS, includeSaturday: true }), + ).toBe(false); + }); + + test("should count optional holidays only when asked to", () => { + expect(isBusinessDayUnderRules(new Date(2024, 1, 12), WEEKDAYS)).toBe(false); + expect( + isBusinessDayUnderRules(new Date(2024, 1, 12), { ...WEEKDAYS, includeOptional: false }), + ).toBe(true); + }); + + test("should add the holidays of the state read", () => { + expect(isBusinessDayUnderRules(new Date(2024, 6, 9), { ...WEEKDAYS, stateCode: "SP" })).toBe( + false, + ); + expect(isBusinessDayUnderRules(new Date(2024, 6, 9), WEEKDAYS)).toBe(true); + }); + + test("should reject a year outside the supported range", () => { + expect(isBusinessDayUnderRules(new Date(2100, 0, 4), WEEKDAYS)).toBe(false); + expect(isBusinessDayUnderRules(new Date(1899, 0, 3), WEEKDAYS)).toBe(false); + }); +}); diff --git a/src/_internals/is-business-day-under-rules/is-business-day-under-rules.ts b/src/_internals/is-business-day-under-rules/is-business-day-under-rules.ts new file mode 100644 index 000000000..fc78e2df8 --- /dev/null +++ b/src/_internals/is-business-day-under-rules/is-business-day-under-rules.ts @@ -0,0 +1,35 @@ +import { isSupportedHolidayYear } from "../is-supported-holiday-year/is-supported-holiday-year"; +import { type BusinessDayRules } from "../read-business-day-rules/read-business-day-rules"; +import { readMemoizedHolidays } from "../read-memoized-holidays/read-memoized-holidays"; + +const SUNDAY = 0; +const SATURDAY = 6; + +/** + * Tells whether a valid date is a business day under rules already read. + * + * @param {Date} value - A valid date. + * @param {BusinessDayRules} rules - The rules read from the options. + * @returns {boolean} `true` when the date is a business day. + */ +export const isBusinessDayUnderRules = (value: Date, rules: BusinessDayRules): boolean => { + const year = value.getFullYear(); + + if (!isSupportedHolidayYear(year)) return false; + + const day = value.getDay(); + + if (day === SUNDAY) return false; + + if (day === SATURDAY && !rules.includeSaturday) return false; + + const month = value.getMonth(); + const date = value.getDate(); + const { includeOptional } = rules; + + return !readMemoizedHolidays(year, rules.stateCode).some((holiday) => { + if (!includeOptional && holiday.type === "optional") return false; + + return holiday.month === month && holiday.day === date; + }); +}; diff --git a/src/_internals/parse-tlv/parse-tlv.test.ts b/src/_internals/parse-tlv/parse-tlv.test.ts index 8dc3b27e1..11ca5967b 100644 --- a/src/_internals/parse-tlv/parse-tlv.test.ts +++ b/src/_internals/parse-tlv/parse-tlv.test.ts @@ -43,6 +43,15 @@ describe("parseTlv", () => { expect(parseTlv("0A0201")).toBeNull(); }); + test("when a character of an id is just outside the range of the digits", () => { + expect(parseTlv("/10201")).toBeNull(); + expect(parseTlv(":10201")).toBeNull(); + expect(parseTlv("1/0201")).toBeNull(); + expect(parseTlv("1:0201")).toBeNull(); + expect(parseTlv("A10201")).toBeNull(); + expect(parseTlv("1A0201")).toBeNull(); + }); + test("when a length is not made of two digits", () => { expect(parseTlv("00A201")).toBeNull(); }); diff --git a/src/_internals/parse-tlv/parse-tlv.ts b/src/_internals/parse-tlv/parse-tlv.ts index 8a30c9858..450fe0758 100644 --- a/src/_internals/parse-tlv/parse-tlv.ts +++ b/src/_internals/parse-tlv/parse-tlv.ts @@ -2,10 +2,14 @@ export type TlvFields = Record; const SEGMENT_LENGTH = 2; -// Stryker disable next-line Regex: id and length are always sliced to at most SEGMENT_LENGTH (2) characters, so dropping either anchor cannot change whether this matches -const SEGMENT_REGEX = /^\d{2}$/; +const ZERO_CODE = 48; -const EMPTY_LENGTH = "00"; +const readTwoDigits = (value: string, index: number): number => { + const tens = value.charCodeAt(index) - ZERO_CODE; + const units = value.charCodeAt(index + 1) - ZERO_CODE; + + return tens >= 0 && tens <= 9 && units >= 0 && units <= 9 ? tens * 10 + units : -1; +}; /** * Parses an EMV® style TLV (tag-length-value) string into its objects. @@ -40,16 +44,16 @@ export const parseTlv = (value: string): TlvFields | null => { let index = 0; while (index < value.length) { - const id = value.slice(index, index + SEGMENT_LENGTH); - const length = value.slice(index + SEGMENT_LENGTH, index + SEGMENT_LENGTH * 2); + const length = readTwoDigits(value, index + SEGMENT_LENGTH); - if (!SEGMENT_REGEX.test(id) || !SEGMENT_REGEX.test(length) || length === EMPTY_LENGTH) - return null; + if (readTwoDigits(value, index) === -1 || length <= 0) return null; + + const id = value.slice(index, index + SEGMENT_LENGTH); if (Object.hasOwn(fields, id)) return null; const start = index + SEGMENT_LENGTH * 2; - const end = start + Number(length); + const end = start + length; if (end > value.length) return null; diff --git a/src/_internals/read-business-day-rules/read-business-day-rules.test.ts b/src/_internals/read-business-day-rules/read-business-day-rules.test.ts new file mode 100644 index 000000000..d59cf69fe --- /dev/null +++ b/src/_internals/read-business-day-rules/read-business-day-rules.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from "../test/runtime"; +import { readBusinessDayRules } from "./read-business-day-rules"; + +describe("readBusinessDayRules", () => { + test("should default to national holidays, optional ones counted and no Saturday", () => { + expect(readBusinessDayRules()).toEqual({ + stateCode: undefined, + includeOptional: true, + includeSaturday: false, + }); + expect(readBusinessDayRules({})).toEqual({ + stateCode: undefined, + includeOptional: true, + includeSaturday: false, + }); + }); + + test("should read every option given", () => { + expect( + readBusinessDayRules({ + stateCode: " sp " as never, + includeOptional: false, + includeSaturday: true, + }), + ).toEqual({ stateCode: "SP", includeOptional: false, includeSaturday: true }); + }); + + test("should return null for a state code that is not a state", () => { + expect(readBusinessDayRules({ stateCode: "XX" as never })).toBeNull(); + expect(readBusinessDayRules({ stateCode: "" as never })).toBeNull(); + expect(readBusinessDayRules({ stateCode: 5 as never })).toBeNull(); + }); +}); diff --git a/src/_internals/read-business-day-rules/read-business-day-rules.ts b/src/_internals/read-business-day-rules/read-business-day-rules.ts new file mode 100644 index 000000000..8be59f9ff --- /dev/null +++ b/src/_internals/read-business-day-rules/read-business-day-rules.ts @@ -0,0 +1,30 @@ +import { type BusinessDayOptions } from "../../is-business-day/is-business-day"; +import { type StateCode } from "../constants/states"; +import { readHolidayStateCode } from "../read-holiday-state-code/read-holiday-state-code"; + +export type BusinessDayRules = { + /** The state whose holidays count, read and validated, or `undefined` for the national ones. */ + stateCode: StateCode | undefined; + /** Whether optional holidays count as non-business days. */ + includeOptional: boolean; + /** Whether Saturday counts as a business day. */ + includeSaturday: boolean; +}; + +/** + * Reads the options once into the rules a business day walk applies to every day. + * + * @param {BusinessDayOptions|undefined} options - The options given to the business day util. + * @returns {BusinessDayRules|null} The rules, or `null` when the state code is not a state. + */ +export const readBusinessDayRules = (options?: BusinessDayOptions): BusinessDayRules | null => { + const stateCode = readHolidayStateCode(options?.stateCode); + + if (stateCode === null) return null; + + return { + stateCode, + includeOptional: options?.includeOptional ?? true, + includeSaturday: options?.includeSaturday ?? false, + }; +}; diff --git a/src/_internals/read-cached/read-cached.test.ts b/src/_internals/read-cached/read-cached.test.ts new file mode 100644 index 000000000..333a3bf6f --- /dev/null +++ b/src/_internals/read-cached/read-cached.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, test } from "../test/runtime"; +import { readCached } from "./read-cached"; + +describe("readCached", () => { + test("should build the value of a key on its first read", () => { + const cache = new Map(); + + expect(readCached(cache, "abc", (key) => key.length)).toBe(3); + expect(cache.get("abc")).toBe(3); + }); + + test("should build each key once and hand back the stored value after that", () => { + const cache = new Map(); + const built: string[] = []; + const build = (key: string) => { + built.push(key); + + return { size: key.length }; + }; + + const first = readCached(cache, "ab", build); + + expect(readCached(cache, "ab", build)).toBe(first); + expect(readCached(cache, "abcd", build)).toEqual({ size: 4 }); + expect(readCached(cache, "ab", build)).toBe(first); + expect(built).toEqual(["ab", "abcd"]); + }); + + test("should read a value that is already in the cache without building", () => { + const cache = new Map([["key", "stored"]]); + + expect(readCached(cache, "key", () => "built")).toBe("stored"); + }); + + test("should keep a falsy value that is not undefined or null", () => { + const cache = new Map(); + let builds = 0; + const build = () => { + builds++; + + return 0; + }; + + expect(readCached(cache, "zero", build)).toBe(0); + expect(readCached(cache, "zero", build)).toBe(0); + expect(builds).toBe(1); + }); +}); diff --git a/src/_internals/read-cached/read-cached.ts b/src/_internals/read-cached/read-cached.ts new file mode 100644 index 000000000..f3d52c7a1 --- /dev/null +++ b/src/_internals/read-cached/read-cached.ts @@ -0,0 +1,29 @@ +const store = (cache: Map, key: Key, build: (key: Key) => Value): Value => { + const value = build(key); + + cache.set(key, value); + + return value; +}; + +/** + * Reads the value a cache holds for a key, building and storing it on the first read of that key. + * The value must never be `undefined` or `null`, which read as missing. + * + * @param {Map} cache - The cache to read and fill. + * @param {Key} key - The key to read. + * @param {(key: Key) => Value} build - Builds the value of a key that is not cached yet. + * @returns {Value} The cached value, or the one just built. + * + * @example + * ```typescript + * const cache = new Map(); + * readCached(cache, "abc", (key) => key.length); // 3 + * readCached(cache, "abc", () => 0); // 3 + * ``` + */ +export const readCached = ( + cache: Map, + key: Key, + build: (key: Key) => Value, +): Value => cache.get(key) ?? store(cache, key, build); diff --git a/src/_internals/read-cid10-category-offsets/read-cid10-category-offsets.test.ts b/src/_internals/read-cid10-category-offsets/read-cid10-category-offsets.test.ts new file mode 100644 index 000000000..730ba7fd7 --- /dev/null +++ b/src/_internals/read-cid10-category-offsets/read-cid10-category-offsets.test.ts @@ -0,0 +1,30 @@ +import { readCid10Subcategories } from "../read-cid10-subcategories/read-cid10-subcategories"; +import { describe, expect, test } from "../test/runtime"; +import { readCid10CategoryOffsets } from "./read-cid10-category-offsets"; + +describe("readCid10CategoryOffsets", () => { + test("should start the first category at 0 and skip one description per subcategory", () => { + const offsets = readCid10CategoryOffsets(); + + expect(offsets["A00"]).toBe(0); + expect(offsets["A01"]).toBe(4); + expect(offsets["A02"]).toBe(10); + }); + + test("should list every category the subcategories table lists, in the same order", () => { + const subcategories = readCid10Subcategories(); + const offsets = readCid10CategoryOffsets(); + let expected = 0; + + for (const category of Object.keys(subcategories)) { + expect(offsets[category]).toBe(expected); + expected += 1 + subcategories[category].length; + } + + expect(Object.keys(offsets)).toHaveLength(2046); + }); + + test("should hand back the same object on every read", () => { + expect(readCid10CategoryOffsets()).toBe(readCid10CategoryOffsets()); + }); +}); diff --git a/src/_internals/read-cid10-category-offsets/read-cid10-category-offsets.ts b/src/_internals/read-cid10-category-offsets/read-cid10-category-offsets.ts new file mode 100644 index 000000000..83fed78c6 --- /dev/null +++ b/src/_internals/read-cid10-category-offsets/read-cid10-category-offsets.ts @@ -0,0 +1,34 @@ +import { readCid10Subcategories } from "../read-cid10-subcategories/read-cid10-subcategories"; + +let cache: Record | undefined; + +/** + * Reads where the description of each CID-10 category sits in `CID10_DESCRIPTIONS`, which lists + * every category followed by its subcategories: a category starts one description plus one per + * subcategory after the previous one. The offsets are built on the first read and the same + * object is handed back after that, so a caller must not change it. + * + * @returns {Record} The index of the description of each category, keyed by + * category. + * + * @example + * ```typescript + * readCid10CategoryOffsets().A00; // 0 + * readCid10CategoryOffsets().A01; // 4 + * ``` + */ +export const readCid10CategoryOffsets = (): Record => { + if (cache !== undefined) return cache; + + const offsets: Record = {}; + let offset = 0; + + for (const [category, subcategories] of Object.entries(readCid10Subcategories())) { + offsets[category] = offset; + offset += 1 + subcategories.length; + } + + cache = offsets; + + return offsets; +}; diff --git a/src/_internals/read-memoized-holidays/read-memoized-holidays.test.ts b/src/_internals/read-memoized-holidays/read-memoized-holidays.test.ts new file mode 100644 index 000000000..96d1fcf63 --- /dev/null +++ b/src/_internals/read-memoized-holidays/read-memoized-holidays.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, test } from "../test/runtime"; +import { readMemoizedHolidays } from "./read-memoized-holidays"; + +describe("readMemoizedHolidays", () => { + test("should list the national holidays of a year as year, month and day", () => { + const holidays = readMemoizedHolidays(2024); + + expect(holidays[0]).toEqual({ + name: "Ano novo", + type: "national", + year: 2024, + month: 0, + day: 1, + }); + expect(holidays).toContainEqual({ + name: "Sexta-feira Santa", + type: "national", + year: 2024, + month: 2, + day: 29, + }); + expect(holidays).toContainEqual({ + name: "Carnaval (segunda-feira)", + type: "optional", + year: 2024, + month: 1, + day: 12, + }); + }); + + test("should add the holidays of a state in date order", () => { + const holidays = readMemoizedHolidays(2024, "SP"); + const paulista = { + name: "Revolução Constitucionalista", + type: "state", + year: 2024, + month: 6, + day: 9, + }; + + expect(holidays).toContainEqual(paulista); + expect(readMemoizedHolidays(2024)).not.toContainEqual(paulista); + + const days = holidays.map(({ month, day }) => month * 100 + day); + + expect(days).toEqual(days.toSorted((a, b) => a - b)); + }); + + test("should hand back the same array for the same year and state", () => { + expect(readMemoizedHolidays(2025, "RJ")).toBe(readMemoizedHolidays(2025, "RJ")); + expect(readMemoizedHolidays(2025, "RJ")).not.toBe(readMemoizedHolidays(2025, "SP")); + expect(readMemoizedHolidays(2025)).not.toBe(readMemoizedHolidays(2026)); + }); +}); diff --git a/src/_internals/read-memoized-holidays/read-memoized-holidays.ts b/src/_internals/read-memoized-holidays/read-memoized-holidays.ts new file mode 100644 index 000000000..9c943e3a2 --- /dev/null +++ b/src/_internals/read-memoized-holidays/read-memoized-holidays.ts @@ -0,0 +1,191 @@ +import { + ELECTION_DATE_OVERRIDES, + ELECTION_HOLIDAY_NAME, + ELECTION_SINCE_YEAR, + FIXED_HOLIDAYS, + type HolidayPeriod, + STATE_HOLIDAYS, +} from "../../get-holidays/constants"; +import { type Holiday, type HolidayType } from "../../get-holidays/get-holidays"; +import { type StateCode } from "../constants/states"; +import { resolveStateHolidayDate } from "../resolve-state-holiday-date/resolve-state-holiday-date"; + +/** + * A holiday as the memo keeps it. A holiday is a local calendar day, so the memo keeps the year, + * month and day rather than the `Date` built from them: a `Date` is an instant, and the same + * instant falls on another local day once the process time zone changes, which would make a + * memoized year answer for the wrong days. + */ +export type MemoizedHoliday = { + /** The holiday name in Brazilian Portuguese. */ + name: string; + /** How the holiday is observed. */ + type: HolidayType; + /** The four digit year. */ + year: number; + /** The zero based month, as `Date#getMonth` counts it. */ + month: number; + /** The day of the month. */ + day: number; +}; + +const cache: Record = {}; + +const memoizeHolidays = (holidays: Holiday[]): MemoizedHoliday[] => + holidays.map(({ name, type, date }) => ({ + name, + type, + year: date.getFullYear(), + month: date.getMonth(), + day: date.getDate(), + })); + +/** + * Whether a holiday entry is in force in `year`: from its `since` (inclusive) up to its `until` + * (exclusive), an absent bound leaving that side open. + * + * @param {number} year - The year being computed. + * @param {HolidayPeriod} period - The entry's bounds. + * @returns {boolean} `true` when the entry applies to `year`. + */ +const isInForce = (year: number, { since, until }: HolidayPeriod): boolean => { + // Stryker disable next-line ConditionalExpression: `since` is undefined for most entries, and `year < undefined` is already always false, so the explicit `since !== undefined` guard never changes the outcome + if (since !== undefined && year < since) return false; + // Stryker disable next-line ConditionalExpression: `until` is undefined for most entries, and `year >= undefined` is already always false, so the explicit `until !== undefined` guard never changes the outcome + if (until !== undefined && year >= until) return false; + + return true; +}; + +const DAYS_IN_WEEK = 7; +const OCTOBER = 9; + +/** + * The date of the first round of the elections of `year`: the first Sunday of October, unless + * `ELECTION_DATE_OVERRIDES` carries another date for that year. Sunday is weekday 0, so the days + * from 1 October to the first Sunday are 7 minus its weekday, or none when 1 October is already a + * Sunday. + * + * @param {number} year - An even year from `ELECTION_SINCE_YEAR` on. + * @returns {Date} The election day, at local midnight. + */ +const resolveElectionDate = (year: number): Date => { + const override = ELECTION_DATE_OVERRIDES[year]; + + if (override !== undefined) return new Date(year, override[0] - 1, override[1]); + + const firstOfOctober = new Date(year, OCTOBER, 1).getDay(); + + return new Date(year, OCTOBER, 1 + ((DAYS_IN_WEEK - firstOfOctober) % DAYS_IN_WEEK)); +}; + +/** + * The holidays of `year`, national and then the ones of `stateCode`, sorted by date. The caller + * passes a state code or `undefined` (`readHolidayStateCode` rejected everything else), so a + * prototype chain key never reaches the state lookup. Name and date together are the identity of + * a holiday: a state entry only replaces a national one when both match, so DF's Corpus Christi + * replaces the national optional one while its Fundação de Brasília is listed next to Tiradentes, + * which falls on the same 21 April under a different name. + * + * @param {number} year - The year, already checked to be supported. + * @param {StateCode | undefined} stateCode - The state whose holidays are added, if any. + * @returns {Holiday[]} The holidays of the year. + */ +const computeHolidays = (year: number, stateCode: StateCode | undefined): Holiday[] => { + const holidays: Holiday[] = []; + + for (const entry of FIXED_HOLIDAYS) { + if (!isInForce(year, entry)) continue; + + holidays.push({ + name: entry.name, + date: new Date(year, entry.month - 1, entry.day), + type: "national", + }); + } + + if (year >= ELECTION_SINCE_YEAR && year % 2 === 0) { + holidays.push({ + name: ELECTION_HOLIDAY_NAME, + date: resolveElectionDate(year), + type: "national", + }); + } + + const easterDate = resolveStateHolidayDate(year, { easterOffset: 0 }); + + holidays.push( + { + name: "Carnaval (segunda-feira)", + date: resolveStateHolidayDate(year, { easterOffset: -48 }), + type: "optional", + }, + { + name: "Carnaval (terça-feira)", + date: resolveStateHolidayDate(year, { easterOffset: -47 }), + type: "optional", + }, + { + name: "Sexta-feira Santa", + date: resolveStateHolidayDate(year, { easterOffset: -2 }), + type: "national", + }, + { + name: "Páscoa", + date: easterDate, + type: "religious", + }, + { + name: "Corpus Christi", + date: resolveStateHolidayDate(year, { easterOffset: 60 }), + type: "optional", + }, + ); + + // Stryker disable next-line ConditionalExpression: `STATE_HOLIDAYS` has no "undefined" key, so indexing it with `undefined` also gives `undefined`; the check only narrows the type. + const stateHolidays = stateCode === undefined ? undefined : STATE_HOLIDAYS[stateCode]; + + if (stateHolidays) { + for (const entry of stateHolidays) { + if (!isInForce(year, entry)) continue; + + const { name, type } = entry; + const date = resolveStateHolidayDate(year, entry); + const stateHoliday: Holiday = { name, date, type: type ?? "state" }; + const stateHolidayKey = `${name}|${date.getTime()}`; + const nationalIndex = holidays.findIndex( + (holiday) => `${holiday.name}|${holiday.date.getTime()}` === stateHolidayKey, + ); + + if (nationalIndex === -1) { + holidays.push(stateHoliday); + } else { + holidays[nationalIndex] = stateHoliday; + } + } + } + + holidays.sort((a, b) => a.date.getTime() - b.date.getTime()); + + return holidays; +}; + +/** + * Reads the memoized holidays of a supported year, computing them on first use. The entries are + * shared between calls, so a caller must only read them. + * + * @param {number} year - A year the holiday tables cover. + * @param {StateCode|undefined} stateCode - A state code already read, or `undefined` for the national list. + * @returns {readonly MemoizedHoliday[]} The shared holidays of the year, in date order. + */ +export const readMemoizedHolidays = ( + year: number, + stateCode?: StateCode, +): readonly MemoizedHoliday[] => { + // Stryker disable next-line StringLiteral: the exact fallback text is never observable outside this module; it only has to be a value no real StateCode equals, which any fixed string satisfies + const cacheKey = `${year}|${stateCode ?? ""}`; + + cache[cacheKey] ??= memoizeHolidays(computeHolidays(year, stateCode)); + + return cache[cacheKey]; +}; diff --git a/src/add-business-days/add-business-days.ts b/src/add-business-days/add-business-days.ts index 51c3bf9a9..2554d91c0 100644 --- a/src/add-business-days/add-business-days.ts +++ b/src/add-business-days/add-business-days.ts @@ -1,9 +1,10 @@ import { HOLIDAYS_MAX_YEAR, HOLIDAYS_MIN_YEAR } from "../_internals/constants/holidays"; import { eachLocalDay } from "../_internals/each-local-day/each-local-day"; +import { isBusinessDayUnderRules } from "../_internals/is-business-day-under-rules/is-business-day-under-rules"; import { isSupportedHolidayYear } from "../_internals/is-supported-holiday-year/is-supported-holiday-year"; import { isValidDate } from "../_internals/is-valid-date/is-valid-date"; -import { readHolidayStateCode } from "../_internals/read-holiday-state-code/read-holiday-state-code"; -import { type BusinessDayOptions, isBusinessDay } from "../is-business-day/is-business-day"; +import { readBusinessDayRules } from "../_internals/read-business-day-rules/read-business-day-rules"; +import { type BusinessDayOptions } from "../is-business-day/is-business-day"; export type { BusinessDayOptions } from "../is-business-day/is-business-day"; @@ -86,7 +87,9 @@ export const addBusinessDays = ( if (!Number.isInteger(amount)) return null; - if (readHolidayStateCode(options?.stateCode) === null) return null; + const rules = readBusinessDayRules(options); + + if (rules === null) return null; if (!isSupportedHolidayYear(date.getFullYear())) return null; @@ -103,7 +106,7 @@ export const addBusinessDays = ( }); for (const candidate of walk) { - if (isBusinessDay(candidate, options)) { + if (isBusinessDayUnderRules(candidate, rules)) { remaining -= 1; if (remaining === 0) { diff --git a/src/capitalize/capitalize.test.ts b/src/capitalize/capitalize.test.ts index 74d6a3f73..cfe59cd3c 100644 --- a/src/capitalize/capitalize.test.ts +++ b/src/capitalize/capitalize.test.ts @@ -6,6 +6,11 @@ import { capitalize, type CapitalizeOptions } from "./capitalize"; describe("capitalize", () => { describe("should capitalize", () => { + test("when the value holds the default upper case words", () => { + expect(capitalize("maria da silva ltda")).toBe("Maria da Silva LTDA"); + expect(capitalize("henrique viii")).toBe("Henrique VIII"); + }); + test("when the value does not contain preposition", () => { expect(capitalize("esponja vegetal")).toBe("Esponja Vegetal"); expect(capitalize("refrigerante 1L")).toBe("Refrigerante 1l"); diff --git a/src/capitalize/capitalize.ts b/src/capitalize/capitalize.ts index e10426ebf..12e252d9c 100644 --- a/src/capitalize/capitalize.ts +++ b/src/capitalize/capitalize.ts @@ -24,6 +24,14 @@ export type CapitalizeOptions = { upperCaseWords?: string[]; }; +const foldToLowerCase = (word: string): string => word.toLowerCase(); + +const foldToUpperCase = (word: string): string => word.toUpperCase(); + +let defaultLowerCaseSet: Set | undefined; + +let defaultUpperCaseSet: Set | undefined; + const toWordSet = ( words: unknown, fallback: readonly string[], @@ -34,6 +42,37 @@ const toWordSet = ( return new Set(source.filter((word) => typeof word === "string").map((word) => fold(word))); }; +const readLowerCaseSet = (words: unknown): Set => { + // Stryker disable next-line ConditionalExpression: for a value that is not an array `toWordSet` falls back to the same default words, so skipping the cache only builds the same set again. + if (Array.isArray(words)) return toWordSet(words, PREPOSITIONS, foldToLowerCase); + + defaultLowerCaseSet ??= toWordSet(words, PREPOSITIONS, foldToLowerCase); + + return defaultLowerCaseSet; +}; + +/** + * The upper case word list in force: the words given, or the default list, which is assembled + * here on the first call and kept, not in a module-level constant, so a bundle that never calls + * `capitalize` does not keep the spread. + * + * @param {unknown} words - The `upperCaseWords` option as given. + * @returns {Set} The words to keep in upper case, in upper case. + */ +const readUpperCaseSet = (words: unknown): Set => { + // Stryker disable next-line ArrayDeclaration: `toWordSet` reads its fallback only when `words` is not an array, and here it is one. + if (Array.isArray(words)) return toWordSet(words, [], foldToUpperCase); + + defaultUpperCaseSet ??= toWordSet( + words, + // Stryker disable next-line ArrayDeclaration: the default set is built once, by the first test that reaches it, whatever the order; an emptied list makes that test fail, but the tests that run later never reach the line. + [...COMPANY_DESIGNATIONS, ...DOCUMENT_ABBREVIATIONS, ...ROMAN_NUMERALS], + foldToUpperCase, + ); + + return defaultUpperCaseSet; +}; + /** * A token that carries a word, as opposed to a separator or the empty token between two of them. * @@ -139,7 +178,7 @@ const isUpperCasePosition = ( if (enclitic) return false; if (ahead.designation === "") return true; - const designation = ahead.designation.toLocaleUpperCase("pt-BR"); + const designation = ahead.designation.toUpperCase(); return ( ahead.joined && COMPANY_DESIGNATIONS.includes(designation) && upperCaseSet.has(designation) @@ -181,10 +220,10 @@ const isStateCodePosition = ( */ const capitalizeWord = (word: string): string => { const [first, ...rest] = word; - const [upperFirst, ...expansion] = first.toLocaleUpperCase("pt-BR"); + const [upperFirst, ...expansion] = first.toUpperCase(); const head = expansion.length > 0 ? first : upperFirst; - return head + rest.join("").toLocaleLowerCase("pt-BR"); + return head + rest.join("").toLowerCase(); }; /** @@ -248,8 +287,8 @@ const capitalizeWord = (word: string): string => { * option never throws. The default `lowerCaseWords` list is the set of prepositions and * conjunctions the Manual de Redação da Presidência da República keeps in lower case inside a * proper name, and the default `upperCaseWords` list is sourced in `constants.ts` from the laws - * that create each designation. The default list is assembled inside the function, not in a - * module-level constant, so a bundle that never calls `capitalize` does not keep the spread. + * that create each designation. The default lists are assembled on the first call and kept, not + * in a module-level constant, so a bundle that never calls `capitalize` does not keep the spread. * * @param {string} value - The input string to be capitalized. * @param {CapitalizeOptions} [options] - Optional configuration for capitalization. @@ -292,15 +331,8 @@ export const capitalize = (value: string, options?: CapitalizeOptions): string = const { lowerCaseWords, upperCaseWords } = options ?? {}; - const lowerCaseSet = toWordSet(lowerCaseWords, PREPOSITIONS, (word) => - word.toLocaleLowerCase("pt-BR"), - ); - - const upperCaseSet = toWordSet( - upperCaseWords, - [...COMPANY_DESIGNATIONS, ...DOCUMENT_ABBREVIATIONS, ...ROMAN_NUMERALS], - (word) => word.toLocaleUpperCase("pt-BR"), - ); + const lowerCaseSet = readLowerCaseSet(lowerCaseWords); + const upperCaseSet = readUpperCaseSet(upperCaseWords); const tokens = value.trim().split(SEPARATOR_REGEX); @@ -323,9 +355,9 @@ export const capitalize = (value: string, options?: CapitalizeOptions): string = continue; } - const lowerCaseWord = token.toLocaleLowerCase("pt-BR"); - const upperCaseWord = token.toLocaleUpperCase("pt-BR"); - const designation = (output.slice(-2).join("") + upperCaseWord).toLocaleUpperCase("pt-BR"); + const lowerCaseWord = token.toLowerCase(); + const upperCaseWord = token.toUpperCase(); + const designation = (output.slice(-2).join("") + upperCaseWord).toUpperCase(); const ahead = lookAhead(tokens, index); if (designation !== upperCaseWord && upperCaseSet.has(designation)) { diff --git a/src/difference-in-business-days/difference-in-business-days.ts b/src/difference-in-business-days/difference-in-business-days.ts index c7d7f6d5e..abd276583 100644 --- a/src/difference-in-business-days/difference-in-business-days.ts +++ b/src/difference-in-business-days/difference-in-business-days.ts @@ -1,8 +1,9 @@ import { eachLocalDay } from "../_internals/each-local-day/each-local-day"; +import { isBusinessDayUnderRules } from "../_internals/is-business-day-under-rules/is-business-day-under-rules"; import { isSupportedHolidayYear } from "../_internals/is-supported-holiday-year/is-supported-holiday-year"; import { isValidDate } from "../_internals/is-valid-date/is-valid-date"; -import { readHolidayStateCode } from "../_internals/read-holiday-state-code/read-holiday-state-code"; -import { type BusinessDayOptions, isBusinessDay } from "../is-business-day/is-business-day"; +import { readBusinessDayRules } from "../_internals/read-business-day-rules/read-business-day-rules"; +import { type BusinessDayOptions } from "../add-business-days/add-business-days"; export type { BusinessDayOptions } from "../is-business-day/is-business-day"; @@ -82,7 +83,9 @@ export const differenceInBusinessDays = ( if (!isValidDate(laterDate)) return null; if (!isValidDate(earlierDate)) return null; - if (readHolidayStateCode(options?.stateCode) === null) return null; + const rules = readBusinessDayRules(options); + + if (rules === null) return null; if (!isSupportedHolidayYear(laterDate.getFullYear())) return null; if (!isSupportedHolidayYear(earlierDate.getFullYear())) return null; @@ -94,7 +97,7 @@ export const differenceInBusinessDays = ( let result = 0; for (const candidate of eachLocalDay({ from: earlierDay, until: laterDay })) { - if (isBusinessDay(candidate, options)) result += step; + if (isBusinessDayUnderRules(candidate, rules)) result += step; } return result; diff --git a/src/get-bank-by-code/get-bank-by-code.ts b/src/get-bank-by-code/get-bank-by-code.ts index e421ce3ee..3cba05881 100644 --- a/src/get-bank-by-code/get-bank-by-code.ts +++ b/src/get-bank-by-code/get-bank-by-code.ts @@ -1,5 +1,5 @@ import { type Bank } from "../_internals/constants/banks"; -import { readBanks } from "../_internals/read-banks/read-banks"; +import { findBank } from "../_internals/find-bank/find-bank"; import { readLookupDigits } from "../_internals/read-lookup-digits/read-lookup-digits"; export type { Bank } from "../_internals/constants/banks"; @@ -35,7 +35,7 @@ export const getBankByCode = (code: string | number): Bank | null => { const normalizedCode = digits.padStart(CODE_LENGTH, "0"); - const bank = readBanks().find((candidate) => candidate.code === normalizedCode); + const bank = findBank("code", normalizedCode); return bank ? { ...bank } : null; }; diff --git a/src/get-bank-by-ispb/get-bank-by-ispb.ts b/src/get-bank-by-ispb/get-bank-by-ispb.ts index 57c73086d..bffaf9265 100644 --- a/src/get-bank-by-ispb/get-bank-by-ispb.ts +++ b/src/get-bank-by-ispb/get-bank-by-ispb.ts @@ -1,6 +1,6 @@ import { type Bank } from "../_internals/constants/banks"; +import { findBank } from "../_internals/find-bank/find-bank"; import { isLookupCode } from "../_internals/is-lookup-code/is-lookup-code"; -import { readBanks } from "../_internals/read-banks/read-banks"; export type { Bank } from "../_internals/constants/banks"; @@ -52,7 +52,7 @@ export const getBankByIspb = (value: string | number): Bank | null => { if (code === "") return null; const ispb = code.padStart(ISPB_LENGTH, "0"); - const bank = readBanks().find((candidate) => candidate.ispb === ispb); + const bank = findBank("ispb", ispb); return bank ? { ...bank } : null; }; diff --git a/src/get-banks/get-banks.ts b/src/get-banks/get-banks.ts index ee76e415c..1e3ce78f4 100644 --- a/src/get-banks/get-banks.ts +++ b/src/get-banks/get-banks.ts @@ -21,4 +21,5 @@ export type { Bank } from "../_internals/constants/banks"; * @see Based on: https://brasilapi.com.br/api/banks/v1 * Fallback source used by the dataset generator (`scripts/banks.ts`) when the Bacen CSV request fails. */ -export const getBanks = (): Bank[] => readBanks().map((bank) => Object.assign({}, bank)); +export const getBanks = (): Bank[] => + readBanks().map(({ code, ispb, name }) => ({ code, ispb, name })); diff --git a/src/get-cid10/get-cid10.ts b/src/get-cid10/get-cid10.ts index b3d1de0bb..cd1915c17 100644 --- a/src/get-cid10/get-cid10.ts +++ b/src/get-cid10/get-cid10.ts @@ -1,5 +1,6 @@ import { CID10_DESCRIPTIONS } from "../_internals/constants/cid10-descriptions"; import { normalizeCid10 } from "../_internals/normalize-cid10/normalize-cid10"; +import { readCid10CategoryOffsets } from "../_internals/read-cid10-category-offsets/read-cid10-category-offsets"; import { readCid10Subcategories } from "../_internals/read-cid10-subcategories/read-cid10-subcategories"; import { unpackTexts } from "../_internals/unpack-texts/unpack-texts"; import { isValidCid10 } from "../is-valid-cid10/is-valid-cid10"; @@ -20,9 +21,7 @@ const findDescriptionIndex = (code: string): number => { const category = code.slice(0, CATEGORY_LENGTH); const subcategory = code.slice(CATEGORY_LENGTH); const subcategories = readCid10Subcategories(); - const categories = Object.keys(subcategories); - const preceding = categories.slice(0, categories.indexOf(category)); - const offset = preceding.reduce((index, listed) => index + 1 + subcategories[listed].length, 0); + const offset = readCid10CategoryOffsets()[category]; return offset + subcategories[category].indexOf(subcategory) + subcategory.length; }; diff --git a/src/get-cities/get-cities.ts b/src/get-cities/get-cities.ts index 7e9ae9ea9..f3a18c53a 100644 --- a/src/get-cities/get-cities.ts +++ b/src/get-cities/get-cities.ts @@ -45,7 +45,7 @@ export const getCities = (state?: StateCode): string[] => { if (!state) { allCitiesCache ??= Object.values(MUNICIPALITY_NAMES) .flatMap((names) => unpackTexts(names)) - .sort((a, b) => a.localeCompare(b, "pt-BR")); + .toSorted((a, b) => a.localeCompare(b, "pt-BR")); return [...allCitiesCache]; } diff --git a/src/get-code-by-municipality-name/get-code-by-municipality-name.ts b/src/get-code-by-municipality-name/get-code-by-municipality-name.ts index e6e87116b..f5f59d4fb 100644 --- a/src/get-code-by-municipality-name/get-code-by-municipality-name.ts +++ b/src/get-code-by-municipality-name/get-code-by-municipality-name.ts @@ -1,9 +1,25 @@ import { MUNICIPALITY_NAMES } from "../_internals/constants/municipality-names"; +import { type StateCode } from "../_internals/constants/states"; import { hasOwnKey } from "../_internals/has-own-key/has-own-key"; import { normalizeMunicipalityName } from "../_internals/normalize-municipality-name/normalize-municipality-name"; import { normalizeStateCode } from "../_internals/normalize-state-code/normalize-state-code"; -import { readMunicipalityCodes } from "../_internals/read-municipality-codes/read-municipality-codes"; -import { unpackTexts } from "../_internals/unpack-texts/unpack-texts"; +import { readCached } from "../_internals/read-cached/read-cached"; +import { readMunicipalities } from "../_internals/read-municipalities/read-municipalities"; + +let codesByName: Map> | undefined; + +const buildCodesByName = (stateCode: StateCode): Map => { + const index = new Map(); + const municipalities = readMunicipalities(stateCode); + + for (let position = municipalities.length - 1; position >= 0; position--) { + const [name, code] = municipalities[position]; + + index.set(normalizeMunicipalityName(name), code); + } + + return index; +}; /** The `getCodeByMunicipalityName` query: a municipality name and the code of its state. */ export type GetCodeByMunicipalityNameParams = { @@ -54,9 +70,10 @@ export const getCodeByMunicipalityName = ( if (!hasOwnKey(MUNICIPALITY_NAMES, normalizedStateCode)) return null; const normalizedName = normalizeMunicipalityName(params.municipalityName); - const index = unpackTexts(MUNICIPALITY_NAMES[normalizedStateCode]).findIndex( - (name) => normalizeMunicipalityName(name) === normalizedName, - ); - return index === -1 ? null : readMunicipalityCodes(normalizedStateCode)[index]; + codesByName ??= new Map(); + + const index = readCached(codesByName, normalizedStateCode, buildCodesByName); + + return index.get(normalizedName) ?? null; }; diff --git a/src/get-cpf-info/get-cpf-info.test.ts b/src/get-cpf-info/get-cpf-info.test.ts index 7e79b0c29..12d5110e3 100644 --- a/src/get-cpf-info/get-cpf-info.test.ts +++ b/src/get-cpf-info/get-cpf-info.test.ts @@ -86,6 +86,19 @@ describe("getCpfInfo", () => { expect(getCpfInfo("12345678909")?.states).toEqual(["PR", "SC"]); }); + + test("without the changes made to the states of an earlier result, whichever way it was changed", () => { + const first = getCpfInfo("12345678909"); + const second = getCpfInfo("12345678909"); + + expect(first?.states).not.toBe(second?.states); + + first?.states.splice(0, 2, "AC"); + second?.states.reverse(); + + expect(getCpfInfo("12345678909")?.states).toEqual(["PR", "SC"]); + expect(getCpfInfo("40152673113")?.states).toEqual(["DF", "GO", "MT", "MS", "TO"]); + }); }); describe("should return null", () => { diff --git a/src/get-cpf-info/get-cpf-info.ts b/src/get-cpf-info/get-cpf-info.ts index d783c75bb..0a7215947 100644 --- a/src/get-cpf-info/get-cpf-info.ts +++ b/src/get-cpf-info/get-cpf-info.ts @@ -1,6 +1,7 @@ import { CPF_BASE_LENGTH, CPF_FISCAL_REGION_BY_STATE } from "../_internals/constants/cpf"; import { STATE_CODES } from "../_internals/constants/state-codes"; import { type StateCode } from "../_internals/constants/states"; +import { readCached } from "../_internals/read-cached/read-cached"; import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; import { isValidCpf } from "../is-valid-cpf/is-valid-cpf"; @@ -20,6 +21,17 @@ export type CpfInfo = { const CHECK_DIGITS_START = CPF_BASE_LENGTH + 1; +let statesByFiscalRegion: Map | undefined; + +const filterStates = (fiscalRegion: string): StateCode[] => + STATE_CODES.filter((state) => CPF_FISCAL_REGION_BY_STATE[state] === fiscalRegion); + +const readStates = (fiscalRegion: string): StateCode[] => { + statesByFiscalRegion ??= new Map(); + + return [...readCached(statesByFiscalRegion, fiscalRegion, filterStates)]; +}; + /** * Reads the fields a CPF (Cadastro de Pessoas Físicas) encodes: the 8 digit base, the Região * Fiscal digit with the states it covers, and the 2 check digits. @@ -70,7 +82,7 @@ export const getCpfInfo = (value: string): CpfInfo | null => { return { base: digits.slice(0, CPF_BASE_LENGTH), fiscalRegion, - states: STATE_CODES.filter((state) => CPF_FISCAL_REGION_BY_STATE[state] === fiscalRegion), + states: readStates(fiscalRegion), checkDigits: digits.slice(CHECK_DIGITS_START), }; }; diff --git a/src/get-holidays/get-holidays.ts b/src/get-holidays/get-holidays.ts index f40bf496c..b86670558 100644 --- a/src/get-holidays/get-holidays.ts +++ b/src/get-holidays/get-holidays.ts @@ -2,15 +2,10 @@ import { HOLIDAYS_MAX_YEAR, HOLIDAYS_MIN_YEAR } from "../_internals/constants/ho import { type StateCode } from "../_internals/constants/states"; import { isNullish } from "../_internals/is-nullish/is-nullish"; import { readHolidayStateCode } from "../_internals/read-holiday-state-code/read-holiday-state-code"; -import { resolveStateHolidayDate } from "../_internals/resolve-state-holiday-date/resolve-state-holiday-date"; import { - ELECTION_DATE_OVERRIDES, - ELECTION_HOLIDAY_NAME, - ELECTION_SINCE_YEAR, - FIXED_HOLIDAYS, - type HolidayPeriod, - STATE_HOLIDAYS, -} from "./constants"; + type MemoizedHoliday, + readMemoizedHolidays, +} from "../_internals/read-memoized-holidays/read-memoized-holidays"; export type { StateCode } from "../_internals/constants/states"; @@ -42,168 +37,13 @@ export type GetHolidaysParams = { */ export type GetHolidaysOptions = GetHolidaysParams; -/** - * A holiday as the memo keeps it. A holiday is a local calendar day, so the memo keeps the year, - * month and day rather than the `Date` built from them: a `Date` is an instant, and the same - * instant falls on another local day once the process time zone changes, which would make a - * memoized year answer for the wrong days. - */ -type MemoizedHoliday = { - name: string; - type: HolidayType; - year: number; - month: number; - day: number; -}; - -const cache: Record = {}; - -const memoizeHolidays = (holidays: Holiday[]): MemoizedHoliday[] => - holidays.map(({ name, type, date }) => ({ - name, - type, - year: date.getFullYear(), - month: date.getMonth(), - day: date.getDate(), - })); - -const buildHolidays = (holidays: MemoizedHoliday[]): Holiday[] => +const buildHolidays = (holidays: readonly MemoizedHoliday[]): Holiday[] => holidays.map(({ name, type, year, month, day }) => ({ name, date: new Date(year, month, day), type, })); -/** - * Whether a holiday entry is in force in `year`: from its `since` (inclusive) up to its `until` - * (exclusive), an absent bound leaving that side open. - * - * @param {number} year - The year being computed. - * @param {HolidayPeriod} period - The entry's bounds. - * @returns {boolean} `true` when the entry applies to `year`. - */ -const isInForce = (year: number, { since, until }: HolidayPeriod): boolean => { - // Stryker disable next-line ConditionalExpression: `since` is undefined for most entries, and `year < undefined` is already always false, so the explicit `since !== undefined` guard never changes the outcome - if (since !== undefined && year < since) return false; - // Stryker disable next-line ConditionalExpression: `until` is undefined for most entries, and `year >= undefined` is already always false, so the explicit `until !== undefined` guard never changes the outcome - if (until !== undefined && year >= until) return false; - - return true; -}; - -const DAYS_IN_WEEK = 7; -const OCTOBER = 9; - -/** - * The date of the first round of the elections of `year`: the first Sunday of October, unless - * `ELECTION_DATE_OVERRIDES` carries another date for that year. Sunday is weekday 0, so the days - * from 1 October to the first Sunday are 7 minus its weekday, or none when 1 October is already a - * Sunday. - * - * @param {number} year - An even year from `ELECTION_SINCE_YEAR` on. - * @returns {Date} The election day, at local midnight. - */ -const resolveElectionDate = (year: number): Date => { - const override = ELECTION_DATE_OVERRIDES[year]; - - if (override !== undefined) return new Date(year, override[0] - 1, override[1]); - - const firstOfOctober = new Date(year, OCTOBER, 1).getDay(); - - return new Date(year, OCTOBER, 1 + ((DAYS_IN_WEEK - firstOfOctober) % DAYS_IN_WEEK)); -}; - -/** - * The holidays of `year`, national and then the ones of `stateCode`, sorted by date. The caller - * passes a state code or `undefined` (`readHolidayStateCode` rejected everything else), so a - * prototype chain key never reaches the state lookup. Name and date together are the identity of - * a holiday: a state entry only replaces a national one when both match, so DF's Corpus Christi - * replaces the national optional one while its Fundação de Brasília is listed next to Tiradentes, - * which falls on the same 21 April under a different name. - * - * @param {number} year - The year, already checked to be supported. - * @param {StateCode | undefined} stateCode - The state whose holidays are added, if any. - * @returns {Holiday[]} The holidays of the year. - */ -const computeHolidays = (year: number, stateCode: StateCode | undefined): Holiday[] => { - const holidays: Holiday[] = []; - - for (const entry of FIXED_HOLIDAYS) { - if (!isInForce(year, entry)) continue; - - holidays.push({ - name: entry.name, - date: new Date(year, entry.month - 1, entry.day), - type: "national", - }); - } - - if (year >= ELECTION_SINCE_YEAR && year % 2 === 0) { - holidays.push({ - name: ELECTION_HOLIDAY_NAME, - date: resolveElectionDate(year), - type: "national", - }); - } - - const easterDate = resolveStateHolidayDate(year, { easterOffset: 0 }); - - holidays.push( - { - name: "Carnaval (segunda-feira)", - date: resolveStateHolidayDate(year, { easterOffset: -48 }), - type: "optional", - }, - { - name: "Carnaval (terça-feira)", - date: resolveStateHolidayDate(year, { easterOffset: -47 }), - type: "optional", - }, - { - name: "Sexta-feira Santa", - date: resolveStateHolidayDate(year, { easterOffset: -2 }), - type: "national", - }, - { - name: "Páscoa", - date: easterDate, - type: "religious", - }, - { - name: "Corpus Christi", - date: resolveStateHolidayDate(year, { easterOffset: 60 }), - type: "optional", - }, - ); - - // Stryker disable next-line ConditionalExpression: `STATE_HOLIDAYS` has no "undefined" key, so indexing it with `undefined` also gives `undefined`; the check only narrows the type. - const stateHolidays = stateCode === undefined ? undefined : STATE_HOLIDAYS[stateCode]; - - if (stateHolidays) { - for (const entry of stateHolidays) { - if (!isInForce(year, entry)) continue; - - const { name, type } = entry; - const date = resolveStateHolidayDate(year, entry); - const stateHoliday: Holiday = { name, date, type: type ?? "state" }; - const stateHolidayKey = `${name}|${date.getTime()}`; - const nationalIndex = holidays.findIndex( - (holiday) => `${holiday.name}|${holiday.date.getTime()}` === stateHolidayKey, - ); - - if (nationalIndex === -1) { - holidays.push(stateHoliday); - } else { - holidays[nationalIndex] = stateHoliday; - } - } - } - - holidays.sort((a, b) => a.date.getTime() - b.date.getTime()); - - return holidays; -}; - /** * Retrieves all Brazilian holidays for a given year. * @@ -417,16 +257,5 @@ export function getHolidays(yearOrOptions: number | GetHolidaysParams): Holiday[ if (normalizedStateCode === null) return []; - // Stryker disable next-line StringLiteral: the exact fallback text is never observable outside this module; it only has to be a value no real StateCode equals, which any fixed string satisfies - const cacheKey = `${year}|${normalizedStateCode ?? ""}`; - - const cached = cache[cacheKey]; - if (cached) { - return buildHolidays(cached); - } - - const holidays = computeHolidays(year, normalizedStateCode); - cache[cacheKey] = memoizeHolidays(holidays); - - return holidays; + return buildHolidays(readMemoizedHolidays(year, normalizedStateCode)); } diff --git a/src/get-municipalities-by-area-code/get-municipalities-by-area-code.ts b/src/get-municipalities-by-area-code/get-municipalities-by-area-code.ts index db04a35e5..28c5bd3d4 100644 --- a/src/get-municipalities-by-area-code/get-municipalities-by-area-code.ts +++ b/src/get-municipalities-by-area-code/get-municipalities-by-area-code.ts @@ -1,10 +1,16 @@ import { type Municipality } from "../_internals/constants/municipalities"; +import { type StateCode } from "../_internals/constants/states"; +import { readCached } from "../_internals/read-cached/read-cached"; import { readMunicipalities } from "../_internals/read-municipalities/read-municipalities"; import { readMunicipalityAreaCode } from "../_internals/read-municipality-area-code/read-municipality-area-code"; import { getAreaCodeInfo } from "../get-area-code-info/get-area-code-info"; export type { Municipality } from "../_internals/constants/municipalities"; +let municipalitiesByAreaCode: + | Map + | undefined; + /** * Lists the Brazilian municipalities that dial a given DDD (area code, the Código Nacional of the * Plano Geral de Numeração). @@ -38,9 +44,15 @@ export const getMunicipalitiesByAreaCode = (areaCode: string | number): Municipa if (info === null) return []; - return info.stateCodes.flatMap((stateCode) => - readMunicipalities(stateCode) - .filter(([, code]) => readMunicipalityAreaCode(stateCode, code) === info.areaCode) - .map(([name, code]) => ({ code, name, stateCode })), + municipalitiesByAreaCode ??= new Map(); + + const entries = readCached(municipalitiesByAreaCode, info.areaCode, () => + info.stateCodes.flatMap((stateCode) => + readMunicipalities(stateCode) + .filter(([, code]) => readMunicipalityAreaCode(stateCode, code) === info.areaCode) + .map(([name, code]): [string, string, StateCode] => [name, code, stateCode]), + ), ); + + return entries.map(([name, code, stateCode]) => ({ code, name, stateCode })); }; diff --git a/src/get-municipalities/get-municipalities.ts b/src/get-municipalities/get-municipalities.ts index c3c465509..348ac99b1 100644 --- a/src/get-municipalities/get-municipalities.ts +++ b/src/get-municipalities/get-municipalities.ts @@ -9,6 +9,8 @@ import { readMunicipalities } from "../_internals/read-municipalities/read-munic export type { Municipality } from "../_internals/constants/municipalities"; export type { StateCode } from "../_internals/constants/states"; +let sortedMunicipalities: Municipality[] | undefined; + const buildMunicipalities = (stateCode: StateCode): Municipality[] => readMunicipalities(stateCode).map(([name, code]) => ({ code, name, stateCode })); @@ -50,9 +52,15 @@ const buildMunicipalities = (stateCode: StateCode): Municipality[] => */ export const getMunicipalities = (stateCode?: StateCode): Municipality[] => { if (stateCode === undefined) { - return STATE_CODES.flatMap((code) => buildMunicipalities(code)).sort((a, b) => - a.name.localeCompare(b.name, "pt-BR"), + sortedMunicipalities ??= STATE_CODES.flatMap((code) => buildMunicipalities(code)).toSorted( + (a, b) => a.name.localeCompare(b.name, "pt-BR"), ); + + return sortedMunicipalities.map(({ code, name, stateCode: state }) => ({ + code, + name, + stateCode: state, + })); } const code = normalizeStateCode(stateCode); diff --git a/src/get-state-code-by-name/get-state-code-by-name.ts b/src/get-state-code-by-name/get-state-code-by-name.ts index 28fc41a69..49db82260 100644 --- a/src/get-state-code-by-name/get-state-code-by-name.ts +++ b/src/get-state-code-by-name/get-state-code-by-name.ts @@ -6,6 +6,18 @@ export type { StateCode } from "../_internals/constants/states"; const normalizeName = (value: string): string => removeAccents(value).replaceAll(/\s+/g, " ").trim().toLowerCase(); +let codesByName: Map | undefined; + +const buildCodesByName = (): Map => { + const index = new Map(); + + for (let position = DATA.length - 1; position >= 0; position--) { + index.set(normalizeName(DATA[position].name), DATA[position].code); + } + + return index; +}; + /** * Retrieves the two-letter code (sigla) of a Brazilian state given its full name. * @@ -34,9 +46,7 @@ const normalizeName = (value: string): string => * ``` */ export const getStateCodeByName = (name: string): StateCode | null => { - const normalized = normalizeName(name); - - const state = DATA.find((entry) => normalizeName(entry.name) === normalized); + codesByName ??= buildCodesByName(); - return state ? state.code : null; + return codesByName.get(normalizeName(name)) ?? null; }; diff --git a/src/is-business-day/is-business-day.ts b/src/is-business-day/is-business-day.ts index 362d57f61..e7f921ff6 100644 --- a/src/is-business-day/is-business-day.ts +++ b/src/is-business-day/is-business-day.ts @@ -1,8 +1,7 @@ import { type StateCode } from "../_internals/constants/states"; -import { isSupportedHolidayYear } from "../_internals/is-supported-holiday-year/is-supported-holiday-year"; +import { isBusinessDayUnderRules } from "../_internals/is-business-day-under-rules/is-business-day-under-rules"; import { isValidDate } from "../_internals/is-valid-date/is-valid-date"; -import { readHolidayStateCode } from "../_internals/read-holiday-state-code/read-holiday-state-code"; -import { getHolidays } from "../get-holidays/get-holidays"; +import { readBusinessDayRules } from "../_internals/read-business-day-rules/read-business-day-rules"; export type { StateCode } from "../_internals/constants/states"; @@ -19,9 +18,6 @@ export type BusinessDayOptions = { includeSaturday?: boolean; }; -const SUNDAY = 0; -const SATURDAY = 6; - /** * Checks whether a given date is a Brazilian business day (dia útil). * @@ -174,30 +170,9 @@ const SATURDAY = 6; export const isBusinessDay = (value: Date, options?: BusinessDayOptions): boolean => { if (!isValidDate(value)) return false; - const stateCode = readHolidayStateCode(options?.stateCode); - - if (stateCode === null) return false; - - const year = value.getFullYear(); - - if (!isSupportedHolidayYear(year)) return false; - - const day = value.getDay(); - - if (day === SUNDAY) return false; - - const includeSaturday = options?.includeSaturday ?? false; - - if (day === SATURDAY && !includeSaturday) return false; - - const includeOptional = options?.includeOptional ?? true; - - const month = value.getMonth(); - const date = value.getDate(); + const rules = readBusinessDayRules(options); - return !getHolidays({ year, stateCode }).some((holiday) => { - if (!includeOptional && holiday.type === "optional") return false; + if (rules === null) return false; - return holiday.date.getMonth() === month && holiday.date.getDate() === date; - }); + return isBusinessDayUnderRules(value, rules); }; diff --git a/src/is-holiday/is-holiday.ts b/src/is-holiday/is-holiday.ts index 618f7db94..d4f0ab850 100644 --- a/src/is-holiday/is-holiday.ts +++ b/src/is-holiday/is-holiday.ts @@ -1,7 +1,9 @@ import { type StateCode } from "../_internals/constants/states"; import { isNullish } from "../_internals/is-nullish/is-nullish"; +import { isSupportedHolidayYear } from "../_internals/is-supported-holiday-year/is-supported-holiday-year"; import { isValidDate } from "../_internals/is-valid-date/is-valid-date"; -import { getHolidays } from "../get-holidays/get-holidays"; +import { readHolidayStateCode } from "../_internals/read-holiday-state-code/read-holiday-state-code"; +import { readMemoizedHolidays } from "../_internals/read-memoized-holidays/read-memoized-holidays"; export type { StateCode } from "../_internals/constants/states"; @@ -102,9 +104,17 @@ export const isHoliday = (options?: IsHolidayParams): boolean => { if (!isValidDate(targetDate)) return false; const year = targetDate.getFullYear(); - return getHolidays({ year, stateCode }).some( - (holiday) => - holiday.date.getMonth() === targetDate.getMonth() && - holiday.date.getDate() === targetDate.getDate(), + + if (!isSupportedHolidayYear(year)) return false; + + const normalizedStateCode = readHolidayStateCode(stateCode); + + if (normalizedStateCode === null) return false; + + const month = targetDate.getMonth(); + const date = targetDate.getDate(); + + return readMemoizedHolidays(year, normalizedStateCode).some( + (holiday) => holiday.month === month && holiday.day === date, ); }; diff --git a/src/is-valid-nfe-key/is-valid-nfe-key.ts b/src/is-valid-nfe-key/is-valid-nfe-key.ts index 4cff5303b..e2e45a125 100644 --- a/src/is-valid-nfe-key/is-valid-nfe-key.ts +++ b/src/is-valid-nfe-key/is-valid-nfe-key.ts @@ -141,7 +141,7 @@ export const isValidNfeKey = (value: string): boolean => { if (!FORMAT_REGEX.test(body)) return false; - const key = sanitizeToAlphanumeric(body).toUpperCase(); + const key = sanitizeToAlphanumeric(body); if (!NFE_KEY_REGEX.test(key)) return false;