diff --git a/src/custom-sort/custom-sort-types.ts b/src/custom-sort/custom-sort-types.ts index aaaa29f1b..c16705bf0 100644 --- a/src/custom-sort/custom-sort-types.ts +++ b/src/custom-sort/custom-sort-types.ts @@ -1,4 +1,5 @@ import {MDataExtractor} from "./mdata-extractors"; +import {MDataMatcher} from "./mdata-matchers"; export enum CustomSortGroupType { Outsiders, // Not belonging to any of other groups @@ -81,6 +82,7 @@ export interface CustomSortGroup { matchFilenameWithExt?: boolean foldersOnly?: boolean withMetadataFieldName?: string // for 'with-metadata:' grouping + withMetadataMatcher?: MDataMatcher // optionally can come for 'with-metadata:' grouping iconName?: string // for integration with obsidian-folder-icon community plugin priority?: number combineWithIdx?: number diff --git a/src/custom-sort/custom-sort.ts b/src/custom-sort/custom-sort.ts index 1ad4f3280..7e8e393f4 100644 --- a/src/custom-sort/custom-sort.ts +++ b/src/custom-sort/custom-sort.ts @@ -476,17 +476,24 @@ export const determineSortingGroup = function (entry: TFile | TFolder, spec: Cus const notePathToScan: string = aFile ? entry.path : `${entry.path}/${entry.name}.md` let frontMatterCache: FrontMatterCache | undefined = ctx._mCache.getCache(notePathToScan)?.frontmatter let hasMetadata: boolean | undefined = frontMatterCache?.hasOwnProperty(group.withMetadataFieldName) + let metadataValue: string | undefined = hasMetadata ? frontMatterCache?.[group.withMetadataFieldName] : undefined // For folders, if index-based folder note mode, scan the index file, giving it the priority if (aFolder) { const indexNoteBasename = ctx?.plugin?.indexNoteBasename() if (indexNoteBasename) { frontMatterCache = ctx._mCache.getCache(`${entry.path}/${indexNoteBasename}.md`)?.frontmatter hasMetadata = hasMetadata || frontMatterCache?.hasOwnProperty(group.withMetadataFieldName) + metadataValue = hasMetadata ? frontMatterCache?.[group.withMetadataFieldName] : undefined } } if (hasMetadata) { - determined = true + if (group.withMetadataMatcher) { + // note: empty metadata value doesn't match anything, by design + determined = !!(metadataValue && group.withMetadataMatcher(metadataValue)) + } else { + determined = true + } } } } diff --git a/src/custom-sort/mdata-extractors.ts b/src/custom-sort/mdata-extractors.ts index 8d37ad448..9f01de7d7 100644 --- a/src/custom-sort/mdata-extractors.ts +++ b/src/custom-sort/mdata-extractors.ts @@ -7,7 +7,7 @@ type ExtractorFn = (mdataValue: string) => string|undefined interface DateExtractorSpec { specPattern: string|RegExp, - extractorFn: ExtractorFn + extractorFn: MDataExtractor } export interface MDataExtractor { diff --git a/src/custom-sort/mdata-matchers.ts b/src/custom-sort/mdata-matchers.ts new file mode 100644 index 000000000..a208f855c --- /dev/null +++ b/src/custom-sort/mdata-matchers.ts @@ -0,0 +1,248 @@ +import { + getNormalizedDate_NormalizerFn_for +} from "./matchers"; +import {NormalizerFn} from "./custom-sort-types"; +import {CollatorCompare, CollatorTrueAlphabeticalCompare} from "./custom-sort"; +import { + SNB, + MDVConverter, + SpecValueConverter, + ValueConverters +} from "./value-converters"; + +type MDataValueType = string|number|boolean|Array +export interface MDataMatcher { + (mdataValue: MDataValueType|undefined): boolean +} + +export type SorNorB = T extends number ? number : (T extends string ? string : boolean) +export type CompareFn = (a: T, b: T) => number + +export interface MDataMatcherFactory { + (specsMatch: string|RegExpMatchArray, + compareFn: CompareFn>, + //mdvConverter: MDVConverter>, + //typeRepresentative: SorNorB + ): MDataMatcher|undefined +} + +interface ValueMatcherSpec { + specPattern: string|RegExp, + valueMatcherFnFactory: MDataMatcherFactory> + compareFn: CompareFn> + //mdvConterter: MDVConverter> + unitTestsId: string +} + +// Syntax sugar to enforce TS type checking on matchers configurations +function newStingValueMatcherSpec(vc: ValueConverters, unitTestId: string, regex: RegExp, trueAlphabetical?: boolean): ValueMatcherSpec { + return { + specPattern: regex, + valueMatcherFnFactory: getPlainValueMatcherFnFactory(vc, vc.specToStringConverter.bind(vc), '' /* type representative */), + compareFn: trueAlphabetical ? CollatorTrueAlphabeticalCompare : CollatorCompare, + unitTestsId: unitTestId + } +} +function newNumberValueMatcherSpec(vc: ValueConverters, unitTestId: string, regex: RegExp, representative: number): ValueMatcherSpec { + return { + specPattern: regex, + valueMatcherFnFactory: getPlainValueMatcherFnFactory( + vc, + (representative == ~~representative) ? vc.specToIntConverter.bind(vc) : vc.specToFloatConverter.bind(vc), + representative + ), + compareFn: (representative == ~~representative) ? CompareIntFn : CompareFloatFn, + unitTestsId: unitTestId + } +} + +function newBooleanValueMatcherSpec(vc: ValueConverters, unitTestId: string, regex: RegExp): ValueMatcherSpec { + return { + specPattern: regex, + valueMatcherFnFactory: getPlainValueMatcherFnFactory(vc, vc.specToBooleanConverter.bind(vc), true /* type representative */), + compareFn: CompareBoolFn, + unitTestsId: unitTestId + } +} + +export interface MDataMatcherParseResult { + m: MDataMatcher + remainder: string +} + +const VALUE_MATCHER_REGEX = /value\(([^)]*)\)/ // 001 === 1 +const STR_VALUE_MATCHER_REGEX = /valueS\(([^)]*)\)/ // 001 === 1 +const VALUE_MATCHER_WITH_DEFAULT_REGEX = /valueD\(([^:]*):([^)]+)\)/ // 001 === 1 +const VALUE_TRUE_ALPHABETIC_MATCHER_REGEX = /valueE\(([^)]*)\)/ // 001 != 1 +const VALUE_TRUE_ALPHABETIC_MATCHER_WITH_DEFAULT_REGEX = /valueED\(([^:]*):([^)]*)\)/ // 001 != 1 + +const INT_VALUE_MATCHER_REGEX = /valueN\((\s*([-+]?\d+(?:E[-+]?\d+)?)\s*)\)/i +const FLOAT_VALUE_MATCHER_REGEX = /valueF\(\s*([-+]?\d+\.\d+(?:E[-+]?\d+)?)\s*\)/i +const BOOL_VALUE_MATCHER_REGEX = /valueB\(\s*(true|false|yes|no|\d)\s*\)/i // for \d only 0 or 1 are accepted, intentionally \d spec here + +function getPlainValueMatcherFnFactory(vc: ValueConverters, specValueConverter: SpecValueConverter>, theType: any): MDataMatcherFactory { + return (specsMatch: RegExpMatchArray, compareFn: CompareFn>): MDataMatcher|undefined => { + const EXACT_VALUE_IDX = 1 // Related to the spec regexp + const DEFAULT_MDATA_VALUE_FOR_EMPTY_VALUE_IDX = 2 // Related to the spec regexp + const expectedValueString: string|undefined = specsMatch[EXACT_VALUE_IDX] // Intentionally not trimming here - string matchers support spaces + const expectedValue: SorNorB|undefined = specValueConverter(expectedValueString) + if (expectedValue===undefined) { + return undefined // syntax error in expected value in spec + } + let mdvConverter: MDVConverter>|undefined = vc.getMdvConverters()[typeof theType] + if (mdvConverter === undefined) { + return undefined // Error in the code, theType should be one of the supported types + } + return (mdataValue: MDataValueType | undefined): boolean => { + const mdvToUse = mdataValue !== undefined ? mdataValue : specsMatch[DEFAULT_MDATA_VALUE_FOR_EMPTY_VALUE_IDX]?.trim() + const mdv = mdvConverter(mdvToUse) + if (mdv === undefined) { + return false // empty metadata value does not match any expected value + } + return compareFn(mdv, expectedValue) === 0 + } + } +} + +const RANGE_MATCHER_REGEX = /range([[(])([^,]*),([^)\]]*)([)\]])/ +const RANGE_TRUE_ALPHABETIC_MATCHER_REGEX = /rangeE([[(])([^,]*),([^)\]]*)([)\]])/ +const RANGE_NUMERIC_MATCHER_REGEX_INT = /rangeN([[(])\s*(-?\d*)\s*,\s*(-?\d*)\s*([)\]])/ +const RANGE_NUMERIC_MATCHER_REGEX_FLOAT = /rangeF([[(])\s*?(-?\d+\.\d+)?\s*,\s*(-?\d+\.\d+)?\s*([)\]])/ +/* + range(aaa,bbb) + range[aaa,bbb) + range(, x) + range( y, ] + */ + +const CompareIntFn: CompareFn = (a: number, b: number) => a - b +const CompareFloatFn: CompareFn = (a: number, b: number) => a - b +const CompareBoolFn: CompareFn = (a: boolean, b: boolean) => a === b ? 0 : (a ? 1 : -1) + +/* +enum RangeEdgeType { INCLUSIVE, EXCLUSIVE} +function getRangeMatcherFn(specsMatch: RegExpMatchArray, compareFn: CompareFn>, mdvConverter: MDVConverter>) { + const RANGE_START_TYPE_IDX = 1 + const RANGE_START_IDX = 2 + const RANGE_END_IDX = 3 + const RANGE_END_TYPE_IDX = 4 + const rangeStartType: RangeEdgeType = specsMatch[RANGE_START_TYPE_IDX] === '(' ? RangeEdgeType.EXCLUSIVE : RangeEdgeType.INCLUSIVE + const rangeStartValue: SorN|undefined = mdvConverter(specsMatch[RANGE_START_IDX]?.trim()) + const rangeEndValue: SorN|undefined = mdvConverter(specsMatch[RANGE_END_IDX]?.trim()) + const rangeEndType: RangeEdgeType = specsMatch[RANGE_END_TYPE_IDX] === ')' ? RangeEdgeType.EXCLUSIVE : RangeEdgeType.INCLUSIVE + return (mdataValue: string|undefined): boolean => { + const mdv: SorN|undefined = mdvConverter(mdataValue?.trim()) + let rangeStartMatched = mdv!==undefined + if (mdv!==undefined && rangeStartValue!==undefined) { // rangeStartValue can be '0' or numeric 0 + if (rangeStartType === RangeEdgeType.INCLUSIVE) { + rangeStartMatched = compareFn (mdv, rangeStartValue) >= 0 + } else { + rangeStartMatched = compareFn (mdv, rangeStartValue) > 0 + } + } + let rangeEndMatched = mdv!==undefined + if (mdv!==undefined && rangeEndValue!==undefined) { // rangeStartValue can be '0' or numeric 0 + if (rangeEndType === RangeEdgeType.INCLUSIVE) { + rangeEndMatched = compareFn (mdv, rangeEndValue) <= 0 + } else { + rangeEndMatched = compareFn (mdv, rangeEndValue) < 0 + } + } + + return rangeStartMatched && rangeEndMatched + } +} +*/ + +let valueMatchersCache: ValueMatcherSpec[]|undefined = undefined + +const valueConverters = new ValueConverters() + +// Dependency injection of valueConverters for unit testing purposes +function getValueMatchers(vc?: ValueConverters) { + return valueMatchersCache ??= [ + newStingValueMatcherSpec(vc ?? valueConverters, 'value', VALUE_MATCHER_REGEX), + newStingValueMatcherSpec(vc ?? valueConverters, 'valueS', STR_VALUE_MATCHER_REGEX), + newStingValueMatcherSpec(vc ?? valueConverters, 'valueD', VALUE_MATCHER_WITH_DEFAULT_REGEX), + newStingValueMatcherSpec(vc ?? valueConverters, 'valueE', VALUE_TRUE_ALPHABETIC_MATCHER_REGEX, true), + newStingValueMatcherSpec(vc ?? valueConverters, 'valueED', VALUE_TRUE_ALPHABETIC_MATCHER_WITH_DEFAULT_REGEX, true), + newNumberValueMatcherSpec(vc ?? valueConverters, 'valueN', INT_VALUE_MATCHER_REGEX, 1 /* type representative */), + newNumberValueMatcherSpec(vc ?? valueConverters, 'valueF', FLOAT_VALUE_MATCHER_REGEX, 1.1 /* type representative */), + newBooleanValueMatcherSpec(vc ?? valueConverters, 'valueB', BOOL_VALUE_MATCHER_REGEX), + /* + + // Range matchers + { + specPattern: RANGE_MATCHER_REGEX, + valueMatcherFnFactory: getRangeMatcherFn, + compareFn: CollatorCompare, + unitTestsId: 'range' + },{ + specPattern: RANGE_TRUE_ALPHABETIC_MATCHER_REGEX, + valueMatcherFnFactory: getRangeMatcherFn, + compareFn: CollatorTrueAlphabeticalCompare, + unitTestsId: 'rangeE' + },{ + specPattern: RANGE_NUMERIC_MATCHER_REGEX_INT, + valueMatcherFnFactory: getRangeMatcherFn, + compareFn: CompareIntFn, + mdvConterter: + unitTestsId: 'rangeN' + },{ + specPattern: RANGE_NUMERIC_MATCHER_REGEX_FLOAT, + valueMatcherFnFactory: getRangeMatcherFn, + compareFn: CompareFloatFn, + mdvConterter: + unitTestsId: 'rangeF' + },*/ { + specPattern: 'any-value', // Artificially added for testing purposes + valueMatcherFnFactory: () => (s: any) => true, + compareFn: (a, b) => 0, // Not used + unitTestsId: 'any-value-explicit' + } + ] +} + +export const tryParseAsMDataMatcherSpec = (s: string): MDataMatcherParseResult|undefined => { + // Simplistic initial implementation of the idea, not closing the way to more complex implementations + for (const matcherSpec of getValueMatchers()) { + if ('string' === typeof matcherSpec.specPattern && s.trim().startsWith(matcherSpec.specPattern)) { + const mdMatcher: MDataMatcher|undefined = matcherSpec.valueMatcherFnFactory(matcherSpec.specPattern, matcherSpec.compareFn) + return mdMatcher ? { + m: mdMatcher, + remainder: s.substring(matcherSpec.specPattern.length).trim() + } : undefined + } else { // regexp + const match = s.match(matcherSpec.specPattern) + if (match) { + const mdMatcher: MDataMatcher|undefined = matcherSpec.valueMatcherFnFactory(match, matcherSpec.compareFn) + return mdMatcher ? { + m: mdMatcher, + remainder: s.substring(match[0].length).trim() + } : undefined + } + } + } + return undefined +} + +export const _unitTests = { + getMatchers(vc: ValueConverters) { + const valueMatchers = getValueMatchers(vc) + return { + matcherFn_value: valueMatchers.find((it) => it.unitTestsId === 'value'), + matcherFn_valueS: valueMatchers.find((it) => it.unitTestsId === 'valueS'), + matcherFn_valueD: valueMatchers.find((it) => it.unitTestsId === 'valueD'), + matcherFn_valueE: valueMatchers.find((it) => it.unitTestsId === 'valueE'), + matcherFn_valueED: valueMatchers.find((it) => it.unitTestsId === 'valueED'), + matcherFn_valueN: valueMatchers.find((it) => it.unitTestsId === 'valueN'), + matcherFn_valueF: valueMatchers.find((it) => it.unitTestsId === 'valueF'), + matcherFn_valueB: valueMatchers.find((it) => it.unitTestsId === 'valueB'), + matcherFn_range: valueMatchers.find((it) => it.unitTestsId === 'range'), + matcherFn_rangeE: valueMatchers.find((it) => it.unitTestsId === 'rangeE'), + matcherFn_rangeN: valueMatchers.find((it) => it.unitTestsId === 'rangeN'), + matcherFn_rangeF: valueMatchers.find((it) => it.unitTestsId === 'rangeF'), + matcherFn_anyValue: valueMatchers.find((it) => it.unitTestsId === 'any-value-explicit'), + } + } +} diff --git a/src/custom-sort/sorting-spec-processor.ts b/src/custom-sort/sorting-spec-processor.ts index 2081339fa..369507565 100644 --- a/src/custom-sort/sorting-spec-processor.ts +++ b/src/custom-sort/sorting-spec-processor.ts @@ -51,6 +51,7 @@ import { NO_PRIORITY } from "./folder-matching-rules" import {MDataExtractor, tryParseAsMDataExtractorSpec} from "./mdata-extractors"; +import {MDataMatcher, tryParseAsMDataMatcherSpec} from "./mdata-matchers"; interface ProcessingContext { folderPath: string @@ -103,7 +104,8 @@ export enum ProblemCode { InlineRegexInPrefixAndSuffix, DuplicateByNameSortSpecForFolder, EmptyFolderNameToMatch, - InvalidOrEmptyFolderMatchingRegexp + InvalidOrEmptyFolderMatchingRegexp, + UnrecognizedMetadataValueMatcher } const ContextFreeProblems = new Set([ @@ -280,6 +282,8 @@ const HideItemVerboseLexeme: string = '/--hide:' const MetadataFieldIndicatorLexeme: string = 'with-metadata:' +const ValueMatcherLexeme: string = 'matching:' + const BookmarkedItemIndicatorLexeme: string = 'bookmarked:' const IconIndicatorLexeme: string = 'with-icon:' @@ -900,6 +904,8 @@ class AttrError { } // Simplistic +// TODO: accept spaces in the name, as already done for the parsing related to extractors for by-metadata +// TODO: update unit tests with metadata names containing spaces const extractIdentifier = (text: string, defaultResult?: string): string | undefined => { const identifier: string = text.trim().split(' ')?.[0]?.trim() return identifier ? identifier : defaultResult @@ -1765,13 +1771,29 @@ export class SortingSpecProcessor { } // theoretically could match the sorting of matched files } else { if (theOnly.startsWith(MetadataFieldIndicatorLexeme)) { - const metadataFieldName: string | undefined = extractIdentifier( - theOnly.substring(MetadataFieldIndicatorLexeme.length), - DEFAULT_METADATA_FIELD_FOR_SORTING - ) + let metadataFieldName: string|undefined = '' + let metadataMatcher: MDataMatcher|undefined = undefined + const metadataNameAndOptionalMatcherSpec = theOnly.substring(MetadataFieldIndicatorLexeme.length).trim() || undefined + if (metadataNameAndOptionalMatcherSpec) { + if (metadataNameAndOptionalMatcherSpec.indexOf(ValueMatcherLexeme) > -1) { + const metadataSpec = metadataNameAndOptionalMatcherSpec.split(ValueMatcherLexeme) + metadataFieldName = metadataSpec.shift()?.trim() + const metadataMatcherSpec = metadataSpec?.shift()?.trim() + const hasMetadataMatcher = metadataMatcherSpec ? tryParseAsMDataMatcherSpec(metadataMatcherSpec) : undefined + if (hasMetadataMatcher) { + metadataMatcher = hasMetadataMatcher.m + } else { + this.problem(ProblemCode.UnrecognizedMetadataValueMatcher, "unrecognized or malformed metadata value matcher specification") + return null; + } + } else { + metadataFieldName = metadataNameAndOptionalMatcherSpec + } + } return { type: CustomSortGroupType.HasMetadataField, - withMetadataFieldName: metadataFieldName, + withMetadataFieldName: metadataFieldName || DEFAULT_METADATA_FIELD_FOR_SORTING, + withMetadataMatcher: metadataMatcher, filesOnly: spec.filesOnly, foldersOnly: spec.foldersOnly, matchFilenameWithExt: spec.matchFilenameWithExt diff --git a/src/custom-sort/value-converters.ts b/src/custom-sort/value-converters.ts new file mode 100644 index 000000000..003913c55 --- /dev/null +++ b/src/custom-sort/value-converters.ts @@ -0,0 +1,121 @@ +type MDataValueType = string|number|boolean|Array +export interface MDataMatcher { + (mdataValue: MDataValueType|undefined): boolean +} + +export type SNB = string|number|boolean +export type SorNorB = T extends number ? number : (T extends string ? string : boolean) +export type CompareFn = (a: T, b: T) => number +export type MDVConverter = (v: MDataValueType|undefined) => T|undefined +export type SpecValueConverter = (s: string|undefined) => T|undefined // undefined => syntax error in spec + +const finiteNumber = (v: any): number|undefined => { + return Number.isFinite(v) ? v : undefined +} + +export interface ValueConvertersAPI { + toIntConverter: MDVConverter + specToIntConverter: SpecValueConverter + toFloatConverter: MDVConverter + specToFloatConverter: SpecValueConverter + toBooleanConverter: MDVConverter + specToBooleanConverter: SpecValueConverter + toStringConverter: MDVConverter + specToStringConverter: SpecValueConverter + + getMdvConverters: () => {[key: string]: MDVConverter} +} + +// To allow unit tests to access the converters and test them and invocations +export class ValueConverters implements ValueConvertersAPI { + constructor() { + } + + toIntConverter(v: MDataValueType | undefined) { + if ('string' === typeof v) { + const vAsFloat = parseFloat(v) // We want to accept scientific notation as well: 1e3 => 1000 + return finiteNumber(vAsFloat) !== undefined ? ~~vAsFloat : undefined + } + if ('number' === typeof v) { + if (Number.isInteger(v)) return v + return finiteNumber(v) !== undefined ? ~~v : undefined + } + if ('boolean' === typeof v) { + return v ? 1 : 0 + } + return undefined // We ignore metadata values of Array type + } + specToIntConverter(v: string | undefined){ + const trimmedTolV = v?.trim().toLowerCase() + return this.toIntConverter(trimmedTolV) + } + toFloatConverter(v: MDataValueType | undefined) { + if ('number' === typeof v) { + return finiteNumber(v) + } + if ('string' === typeof v) { + const trimmedV = v.trim() + return trimmedV ? finiteNumber(parseFloat(v)) : undefined // empty string => undefined, unrecognized => NaN + } + if ('boolean' === typeof v) { + return v ? 1 : 0 + } + return undefined // We ignore metadata values of Array type + } + specToFloatConverter(v: string | undefined) { + const trimmedTolV: string | undefined = v?.trim().toLowerCase() + return this.toFloatConverter(trimmedTolV) + } + toBooleanConverter(v: MDataValueType | undefined) { + if ('boolean' === typeof v) { + return v + } + if ('number' === typeof v) { + return !!v // Apply standard JS to-boolean conversion rules + } + if ('string' === typeof v) { + const v2l = v.trim().toLowerCase() + return (v2l === 'true' || v2l === 'yes' || v2l === '1') + ? + true + : ((v2l === 'false' || v2l === 'no' || v2l === '0') ? false : undefined) + } + return undefined // We ignore metadata values of Array type + } + specToBooleanConverter(v: string | undefined) { + const v2l = v?.trim().toLowerCase() + return this.toBooleanConverter(v2l) + } + toStringConverter(v: MDataValueType | undefined) { + if ('string' === typeof v) { + return v + } + if ('number' === typeof v || 'boolean' === typeof v) { + return v.toString() + } + return undefined // We ignore metadata values of Array type + } + specToStringConverter(v: string | undefined) { + return v?.toLowerCase() + } + + private _mdvConvertersCache: {[key: string]: MDVConverter} + getMdvConverters() { + this._mdvConvertersCache = { + string: this.toStringConverter, + number: this.toFloatConverter, + boolean: this.toBooleanConverter + } + this.getMdvConverters = this.getMdvConvertersFromCache + return this.getMdvConvertersFromCache() + } + private getMdvConvertersFromCache() { + return this._mdvConvertersCache + } +} + + + + + + diff --git a/src/test/unit/mdata-matchers.spec.ts b/src/test/unit/mdata-matchers.spec.ts new file mode 100644 index 000000000..bdb5cc78b --- /dev/null +++ b/src/test/unit/mdata-matchers.spec.ts @@ -0,0 +1,523 @@ +import {ValueConverters} from '../../custom-sort/value-converters' +import { + tryParseAsMDataMatcherSpec, + _unitTests +} from '../../custom-sort/mdata-matchers' + +let valueConverters: ValueConverters + +// Wrap the ValueConverters to check if they are called +function mockValueConverters() { + if (!valueConverters) { + valueConverters = new ValueConverters() + valueConverters.toBooleanConverter = jest.fn(valueConverters.toBooleanConverter) + valueConverters.specToBooleanConverter = jest.fn(valueConverters.specToBooleanConverter) + valueConverters.toStringConverter = jest.fn(valueConverters.toStringConverter) + valueConverters.specToStringConverter = jest.fn(valueConverters.specToStringConverter) + valueConverters.toIntConverter = jest.fn(valueConverters.toIntConverter) + valueConverters.specToIntConverter = jest.fn(valueConverters.specToIntConverter) + valueConverters.toFloatConverter = jest.fn(valueConverters.toFloatConverter) + valueConverters.specToFloatConverter = jest.fn(valueConverters.specToFloatConverter) + + // Populate initial module cache + _unitTests.getMatchers(valueConverters) + } + jest.clearAllMocks() +} + +/* Convenience syntax sugar */ +const R /* Result type*/ = { + MATCH: 'spec ok, match', + NO_MATCH: 'spec ok, no-match', + Err: { + SPEC_SYNTAX: 'spec syntax error', + SPEC_VALUE: 'spec value error', + } +} + +describe('MDataMatcher - exact value matchers', () => { + it('value(...) should correctly parse plain value matcher and do the matching - plain syntax', () => { + const matcher = tryParseAsMDataMatcherSpec('value(testValue)') + expect(matcher!).toBeDefined() + expect(matcher!.m('testValue')).toBe(true) + expect(matcher!.m('otherValue')).toBe(false) + expect(matcher!.remainder).toBe('') + }) + it('value(...) should correctly parse plain value matcher and do the matching - syntax with spaces', () => { + const result = tryParseAsMDataMatcherSpec('value( test value ) ') + expect(result).toBeDefined() + expect(result!.m('test value')).toBe(true) + expect(result!.m('other value')).toBe(false) + expect(result!.m('')).toBe(false) + expect(result!.remainder).toBe('') + }) + it('value(...) should correctly parse plain value matcher and do the matching - syntax with spaces and remainder', () => { + const result = tryParseAsMDataMatcherSpec('value( test Value ) some remainder') + expect(result).toBeDefined() + expect(result!.m('test Value')).toBe(true) + expect(result!.m('otherValue')).toBe(false) + expect(result!.remainder).toBe('some remainder') + }) + it('value(...) should correctly parse plain value matcher and do the matching - numbers', () => { + const matcher = tryParseAsMDataMatcherSpec('value(123.01)') + expect(matcher!).toBeDefined() + expect(matcher!.m('123.01')).toBe(true) + expect(matcher!.m('123.1')).toBe(true) + expect(matcher!.m('0123.01')).toBe(true) + expect(matcher!.m('00123.000')).toBe(false) + expect(matcher!.m('0000123.0001')).toBe(true) + expect(matcher!.remainder).toBe('') + }) + it('valueE(...) should correctly parse plain value matcher and do the matching - numbers and true alphabetical comparison', () => { + const matcher = tryParseAsMDataMatcherSpec('valueE(123.01)') + expect(matcher!).toBeDefined() + expect(matcher!.m('123.01')).toBe(true) + expect(matcher!.m('123.1')).toBe(false) + expect(matcher!.m('0123.01')).toBe(false) + expect(matcher!.m('00123.000')).toBe(false) + expect(matcher!.m('0000123.0001')).toBe(false) + expect(matcher!.remainder).toBe('') + }) + it('value(...) should reject error empty value spec', () => { + const result = tryParseAsMDataMatcherSpec('value()') + expect(result).toBeUndefined() + }) + + it('should correctly parse any-value matcher', () => { + const result = tryParseAsMDataMatcherSpec('any-value') + expect(result).toBeDefined() + expect(result!.m('anyValue')).toBe(true) + expect(result!.m('anotherValue')).toBe(true) + expect(result!.m('')).toBe(true) + expect(result!.remainder).toBe('') + }) + + it('should return undefined for unknown matcher', () => { + const result = tryParseAsMDataMatcherSpec('unknown-matcher') + expect(result).toBeUndefined() + }) +}) + +describe('MDataMatcher - exact value matchers - syntax errors', () => { + it.each([ + 'value()', + 'valueS()', + 'valueD()', + 'valueE()', + 'valueED()', + 'valueN()', + 'valueF()', + 'valueF(-1000)', + 'valueB()', + ])('should correctly reject syntax error in %s', (spec) => { + const matcher = tryParseAsMDataMatcherSpec(spec) + expect(matcher).toBeUndefined() + }) +}) + +describe('MDataMatcher - value*() - test-as-specification cases', () => { + it.each([ + ['value( )', ' ',], + ['value( )', ' '], + ['valueS( )', ' '], + ['valueS( )', ' '], + ['valueD(100:00100)', undefined], + ['valueD(100:00100)', undefined], + ['valueD(abc:abc)', undefined], + ['valueE)', ''], + ['valueED()', ''], + ['valueN()', ''], + ['valueF(1000.00000)', 1000], + ['valueF(1000.00000)', 1000.0000000000], + ['valueF(-1000.0)', -1000], + ['valueF(-1000.0)', '-1.0e3'], + ['valueF(-1000.0)', -1.0e3], + ['valueF(-1000.0)', -1.0e+3], + ['valueF(1.0)', true], + ['valueF(0.0)', false], + ['valueB()', ''], + ['valueB()', ''], + ['valueB()', undefined], + ['valueB()', 1], + ['valueB(true)', true], + ['valueB()', ['a', 1, false]], + ])('the %s should match %s', (spec, mdv ) => { + mockValueConverters() + const matcher = tryParseAsMDataMatcherSpec(spec) + expect(matcher).toBeDefined() + expect(matcher!.m(mdv)).toBeTruthy() + expect(matcher!.remainder).toBe('') + expect(valueConverters.specToBooleanConverter).toHaveBeenCalled() + expect(valueConverters.toBooleanConverter).toHaveBeenCalled() + }) + + it.each([ + ['valueB(trUe )', true, R.MATCH], + ['valueB( falsE)', false, R.MATCH], + ['valueB(yEs)', true, R.MATCH], + ['valueB(No)', false, R.MATCH], + ['valueB(true)', ['a', 1, false], R.NO_MATCH], + ['valueB(false)', ['a', 1, false], R.NO_MATCH], + ['valueB(1)', true, R.MATCH], + ['valueB(0)', false, R.MATCH], + ['valueB(0)', true, R.NO_MATCH], + ['valueB(1)', false, R.NO_MATCH], + ['valueB()', undefined, R.Err.SPEC_SYNTAX], + ['valueB(abc)', undefined, R.Err.SPEC_SYNTAX], + ['valueB( unknown )', undefined, R.Err.SPEC_SYNTAX], + ['valueB(5)', undefined, R.Err.SPEC_VALUE], + ['valueB(9)', undefined, R.Err.SPEC_VALUE], + ])('valueB: the %s spec and %s - case type (%s)', (spec, mdv, caseType: string) => { + mockValueConverters() + const matcher = tryParseAsMDataMatcherSpec(spec) + if (caseType === R.Err.SPEC_SYNTAX) { + expect(matcher).toBeUndefined() + expect(valueConverters.specToBooleanConverter).toHaveBeenCalledTimes(0) + expect(valueConverters.toBooleanConverter).toHaveBeenCalledTimes(0) + } else if (caseType === R.Err.SPEC_VALUE) { + expect(matcher).toBeUndefined() + expect(valueConverters.specToBooleanConverter).toHaveBeenCalledTimes(1) + expect(valueConverters.toBooleanConverter).toHaveBeenCalledTimes(1) + } else { + expect(matcher).toBeDefined() + expect(matcher!.remainder).toBe('') + expect(valueConverters.specToBooleanConverter).toHaveBeenCalledTimes(1) + expect(valueConverters.toBooleanConverter).toHaveBeenCalledTimes(1) + if (caseType === R.MATCH) { + expect(matcher!.m(mdv)).toBeTruthy() + } else if (caseType === R.NO_MATCH) { + expect(matcher!.m(mdv)).toBeFalsy() + } else { + expect('Invalid test case').toBeFalsy() + } + expect(valueConverters.specToBooleanConverter).toHaveBeenCalledTimes(1) + expect(valueConverters.toBooleanConverter).toHaveBeenCalledTimes(2) + } + }) + + it.each([ + // value() and valueD() (string alphanumeric comparison) + ['value()', '', R.MATCH], + ['valueS()', '', R.MATCH], + ['value( )', ' ', R.MATCH], + ['value()', ' ', R.NO_MATCH], + ['value( )', '', R.NO_MATCH], + ['valueS( )', '', R.NO_MATCH], + ['value(Abc)', 'Abc', R.MATCH], + ['value(Abc)', 'ABC', R.MATCH], + ['value(Abc)', ' Abc ', R.NO_MATCH], + ['value(Abc)', 'Abcd', R.NO_MATCH], + ['value(123)', 123, R.MATCH], + ['value( 123)', 123, R.NO_MATCH], + ['value(0123)', 123, R.MATCH], + ['value(0123.456)', 123.456, R.MATCH], + ['valueS(0123.456)', 123.456, R.MATCH], + ['value(false)', false, R.MATCH], + ['value(TrUe)', true, R.MATCH], + ['value( TrUe )', true, R.NO_MATCH], + ['value(true)', ['a', 1, false], R.NO_MATCH], + ['value(false)', ['a', 1, false], R.NO_MATCH], + ['value()', undefined, R.NO_MATCH], + ['value(undefined)', undefined, R.NO_MATCH], + ['value(0123.456.000)', 123.456, R.NO_MATCH], + ['value(0123.456.001)', 123.456, R.NO_MATCH], + ['value(0)', '0', R.MATCH], + ['value(0)', '00000', R.MATCH], + ['valueS(0)', '00000', R.MATCH], + ['value(0)', 0, R.MATCH], + ['value(0)', '', R.NO_MATCH], + ['valueD(0:0)', '', R.NO_MATCH], + ['valueD(0:0)', undefined, R.MATCH], + ['value(0)', '', R.NO_MATCH], + ['value(1)', '', R.NO_MATCH], + // For string matching there are no syntax errors in the spec by definition + //['value()', '', R.Err.SPEC_SYNTAX], + //['value(abc)', '', R.Err.SPEC_SYNTAX], + //['value( unknown )', '', R.Err.SPEC_SYNTAX], + //['value(5)', '', R.Err.SPEC_VALUE], + //['value(9)', '', R.Err.SPEC_VALUE], + + // valeE() and valueED() (string true alphabetical comparison) + ['valueE()', '', R.MATCH], + ['valueE()', undefined, R.NO_MATCH], + ['valueED(:)', '', R.MATCH], + ['valueED(:)', undefined, R.MATCH], + ['valueE( )', ' ', R.MATCH], + ['valueE()', ' ', R.NO_MATCH], + ['valueE( )', '', R.NO_MATCH], + ['valueE(Abc)', 'Abc', R.MATCH], + ['valueE(Abc)', 'ABC', R.MATCH], + ['valueE(Abc)', ' Abc ', R.NO_MATCH], + ['valueE(Abc)', 'Abcd', R.NO_MATCH], + ['valueE(123)', 123, R.MATCH], + ['valueE( 123)', 123, R.NO_MATCH], + ['valueE(0123)', 123, R.NO_MATCH], + ['valueE(0123.456)', 123.456, R.NO_MATCH], + ['valueE(123.0456)', 123.456, R.NO_MATCH], + ['valueE(false)', false, R.MATCH], + ['valueE(TrUe)', true, R.MATCH], + ['valueE( TrUe )', true, R.NO_MATCH], + ['valueE(true)', ['a', 1, false], R.NO_MATCH], + ['valueE(false)', ['a', 1, false], R.NO_MATCH], + ['valueE()', undefined, R.NO_MATCH], + ['valueE(undefined)', undefined, R.NO_MATCH], + ['valueE(0123.456.000)', 123.456, R.NO_MATCH], + ['valueE(0123.456.001)', 123.456, R.NO_MATCH], + ['valueE(0)', '0', R.MATCH], + ['valueE(0)', '00000', R.NO_MATCH], + ['valueED(0:0)', '00000', R.NO_MATCH], + ['valueE(0)', 0, R.MATCH], + ['valueE(0)', '', R.NO_MATCH], + ['valueED(0:000)', '', R.NO_MATCH], + ['valueED(0:000)', undefined, R.NO_MATCH], + ['valueED(000:000)', undefined, R.MATCH], + ['valueE(0)', '', R.NO_MATCH], + ['valueE(1)', '', R.NO_MATCH], + + ])('value*: the %s spec and >%s< - case type (%s)', (spec, mdv, caseType: string) => { + mockValueConverters() + const matcher = tryParseAsMDataMatcherSpec(spec) + if (caseType === R.Err.SPEC_SYNTAX) { + expect(matcher).toBeUndefined() + expect(valueConverters.specToStringConverter).toHaveBeenCalledTimes(0) + expect(valueConverters.toStringConverter).toHaveBeenCalledTimes(0) + } else if (caseType === R.Err.SPEC_VALUE) { + expect(matcher).toBeUndefined() + expect(valueConverters.specToStringConverter).toHaveBeenCalledTimes(1) + expect(valueConverters.toStringConverter).toHaveBeenCalledTimes(0) + } else { + expect(matcher).toBeDefined() + expect(matcher!.remainder).toBe('') + expect(valueConverters.specToStringConverter).toHaveBeenCalledTimes(1) + expect(valueConverters.toStringConverter).toHaveBeenCalledTimes(0) // specToStringConverter doesn't need string-to-string conversion + if (caseType === R.MATCH) { + expect(matcher!.m(mdv)).toBeTruthy() + } else if (caseType === R.NO_MATCH) { + expect(matcher!.m(mdv)).toBeFalsy() + } else { + expect('Invalid test case').toBeFalsy() + } + expect(valueConverters.specToStringConverter).toHaveBeenCalledTimes(1) + expect(valueConverters.toStringConverter).toHaveBeenCalledTimes(1) + } + }) + + it('value()) - closing parenthesis is not part of value, it is the ignored remainder', () => { + const matcher = tryParseAsMDataMatcherSpec('value())') + expect(matcher).toBeDefined() + expect(matcher!.remainder).toBe(')') + expect(matcher!.m('')).toBeTruthy() + }) + + +}) + +describe('MDataMatcher - value*() - test-as-specification negative cases', () => { + it.each([ + ['value( )', ' ',], + ['value( )', ' '], + ['valueS( )', ' '], + ['valueS( )', ' '], + ['valueD(abc:abc)', ' '], + ['valueD(abc:abc)', ''], + ['valueE)', ''], + ['valueED()', ''], + ['valueN()', ''], + ['valueF()', ''], + ['valueB()', ''], + ['valueB()', ''], + ['valueB()', undefined], + ['valueB()', 1], + ['valueB()', true], + ['valueB()', ['a', 1, false]], + ])('the %s should NOT match %s', (spec, mdv ) => { + const matcher = tryParseAsMDataMatcherSpec(spec) + expect(matcher).toBeDefined() + expect(matcher!.m(mdv)).toBeFalsy() + expect(matcher!.remainder).toBe('') + }) +}) + +describe('MDataMatcher - range matcher', () => { + it.each([ + // Default alphabetical comparison + ['range[aaa,bbb)', 'aaa', true], + ['range[ aaa, bbb)', 'aax', true], + ['range[aaa ,bbb )', 'aa', false], + ['range[ aaa , bbb )', 'bbb', false], + ['range( aaa,bbb]', 'aaaa', true], + ['range(aaa ,bbb]', 'bbb', true], + ['range(aaa, bbb]', 'aaa', false], + ['range( aaa , bbb ]', 'bbc', false], + ['range(,456)', '1', true], + ['range(,456)', '456', false], + ['range(,456)', '0456', false], + ['range(,456)', '1000', false], + ['range(123,)', '1', false], + ['range(123,)', '01', false], + ['range(123,)', '123', false], + ['range(123,)', '0123', false], + ['range(123,)', '1000', true], + + // True alphabetical comparison + ['rangeE(,456)', '1', true], + ['rangeE(,456)', '456', false], + ['rangeE(,456)', '0456', true], + ['rangeE(,456)', '1000', true], + ['rangeE(123,)', '1', false], + ['rangeE(123,)', '01', false], + ['rangeE(123,)', '123', false], + ['rangeE(123,)', '124', true], + ['rangeE(123,)', '0123', false], + ['rangeE(123,)', '1000', false], + + // Trickier cases + ['range[2025-02-17,2025-02-17]', '2025-02-17', true], + ['range[2025-02-17,2025-02-17]', '2025-2-17', true], + ['range[2025-02-17,2025-02-17]', '002025-2-017', true], + + ['rangeE[2025-02-17,2025-02-17]', '2025-02-17', true], + ['rangeE[2025-02-17,2025-02-17]', '2025-2-17', false], + ['rangeE[2025-02-17,2025-02-17]', '002025-2-017', false], + + // Edge cases + ['range(1,1)', '1', false], + ['range[1,1)', '1', false], + ['range(1,1]', '1', false], + ['range[1,1]', '1', true], + ['range(,)', '1', true], + ['range(,)', '', true], + ['range(,)', 'anything', true], + + // range[-1,1] is not what you would expect, it is not numerical comparison + ['range[-1,1]', '-10', true], + ['range[-1,1]', '-2', true], + ['range[-1,1]', '-1', true], + ['range[-1,1]', '0', true], + ['range[-1,1]', '1', true], + ['range[-1,1]', '2', false], + ])('should correctly parse range matcher %s and evaluate against %s', (spec, value, result) => { + const matcher = tryParseAsMDataMatcherSpec(spec) + expect(matcher).toBeDefined() + expect(matcher!.m(value)).toBe(result) + expect(matcher!.remainder).toBe('') + }) + it.each([ + // Numerical ranges + + // rangeN[-1,1] is exactly what you would expect, numerical comparison + ['rangeN[-1,1]', '-10', false], + ['rangeN[-1,1)', '-2', false], + ['rangeN[-1,1)', '-1', true], + ['rangeN(-1,1)', '0', true], + ['rangeN(-1,1]', '1', true], + ['rangeN[-1,1]', '2', false], + + // tricky - mdata value interpreted as integer + ['rangeN[-1,1]', '1.0', true], + ['rangeN[-1,1]', '1.1', true], + ['rangeN[-1,1]', '-1.2', true], + + // rangeF[-1.5,1.5] is numerical, floating point correct behavior + ['rangeF[-1.0,1.0]', '-10', false], + ['rangeF[-1.0,1.0]', '-1.0', true], + ['rangeF[-1.0,1.0]', '-1.1', false], + ['rangeF[-1.0,1.0]', '1.1', false], + ['rangeF[-10.0,11.0]', '-10.1', false], + ['rangeF(-1.0,1.0]', '-2', false], + ['rangeF(-0.456,1.0]', '-0.455', true], + ['rangeF(-0.456,2.0]', '-0.456', false], + ['rangeF[-0.456,0.999]', '-0.456', true], + ['rangeF(-0.456,444.4]', '-0.457', false], + + // reverse range spec - always false + ['rangeF[1.0,-1.0]', '0', false], + + // partial ranges and zero as range + ['rangeF(,123.0)', '0', true], + ['rangeF[123.0,456.0]', '0', false], + ['rangeF(456.0,)', '0', false], + ['range(0,0)', '0', false], + ['range[0,0]', '0', true], + ['rangeN(0,0)', '0', false], + ['rangeN[0,0]', '0', true], + ['range(0,0)', '0', false], + ['range[0,0]', '0', true], + ['range(0,10)', '10', false], + ['range[0,10]', '10', true], + ['rangeN(0,10)', '10', false], + ['rangeN[0,10]', '10', true], + + // NaN and non-float or not-numeric values + ['rangeF[1000.1,1000.999]', '', false], + ['rangeF[1000.1,1000.999]', 'abc', false], + ['rangeF[1000.1,1000.999]', '+1000.12bvcs', true], + ['rangeF[-1000.1,1000.999]', '-.0', true], + ['rangeF[-1000.1,1000.999]', '0.', true], + + ])('should correctly parse numerical range matcher %s and evaluate against %s', (spec, value, result) => { + const matcher = tryParseAsMDataMatcherSpec(spec) + expect(matcher).toBeDefined() + expect(matcher!.m(value)).toBe(result) + expect(matcher!.remainder).toBe('') + }) + it.each([ + // invalid rangeN full explicit [-]N format required + 'rangeN[1.,1]', + 'rangeN[1.,1.1]', + 'rangeN[1.1,1.]', + 'rangeN[-1.,1.]', + 'rangeN[-1.,-1.]', + 'rangeN[0,.1]', + + // invalid rangeF syntax - full explicit [-]N.N format required + 'rangeF[1,1]', + 'rangeF[1.,1.1]', + 'rangeF[1.1,1.]', + 'rangeF[-1.,1.]', + 'rangeF[-1.,-1.]', + 'rangeF[.0,.1]', + 'rangeF[.,1.0]', + + ])('should not parse not strictly formatted rangeN or rangeF matcher %s ', (spec) => { + const matcher = tryParseAsMDataMatcherSpec(spec) + expect(matcher).toBeUndefined() + }) +}) + +/* +Test cases coverage check list +1. Test converters, all cases +2. Test exact value matchers if they invoke the converter and the matching logic correctly, minimal cases + +- string matcher: + - true alphabetical + - negative: numbers are treated literally hence 010 doesn't match 10 + - alphabetical + - positive: 010 matches 10 and similar + - negative: 020.030 doesn't match 20.3 and similar + +- string matcher with default: + - 2 positive cases: string, + - 2 negative cases + +- int matcher + - positive: expecting int format, various examples (100, 00001, -000003, 0) + - positive: partial parsing of string, e.g. 100.34 -> 100. 1000abc -> 1000, -3434xyz -> -3434 + - negative: invalid formats of spec, non-float values of mdata (strings not convertible to float, NaN, Infinity) + +- float matcher + - positive: expecting full float format plus scientific notation, various examples + - negative: invalid formats of spec, non-float values of mdata (strings not convertible to float, NaN, Infinity) + +- bool matcher + - various conversions to bool + - positive, in spec and in mdv + - negative, in spec and in mdv + +For ranges: + - assume reuse of logic exact matches - can it be covered by test? + - then only test the range logic itself + + + */ diff --git a/src/test/unit/value-converters.spec.ts b/src/test/unit/value-converters.spec.ts new file mode 100644 index 000000000..eda761920 --- /dev/null +++ b/src/test/unit/value-converters.spec.ts @@ -0,0 +1,184 @@ +import { + ValueConverters +} from '../../custom-sort/value-converters' + +describe('toBooleanConverter', () => { + let valueConverters: ValueConverters + beforeAll(() => { + valueConverters = new ValueConverters() + }) + it.each([ + [true, true], + [false, false], + ['true', true], + [' true', true], + ['True', true], + ['True ', true], + ['TRUE', true], + ['yes', true], + [' yes ', true], + ['Yes', true], + ['yeS', true], + ['no', false], + ['No', false], + ['NO', false], + ['false', false], + ['False', false], + ['fALSE', false], + ['1', true], + ['0', false], + [1, true], + [1000, true], + [-4356, true], + [0, false], + [0.0, false], + [NaN, false], + [Infinity, true], + [-Infinity, true] + ])('should correctly convert %s to boolean %s', (v, ev: boolean ) => { + const bool = valueConverters.toBooleanConverter(v) + expect(bool).toBe(ev) + }) + it.each([ + '', + ' ', + '10', + '00', + '-', + 'true1', + undefined, + ['a','b'], // metadata value in Obsidian can be also an array + ])('should not convert %s to boolean', (v) => { + const bool = valueConverters.toBooleanConverter(v) + expect(bool).toBeUndefined() + }) +}) + +describe('toStringConverter', () => { + let valueConverters: ValueConverters + beforeAll(() => { + valueConverters = new ValueConverters() + }) + it.each([ + ['', ''], + [' ', ' '], + [false, 'false'], + [true, 'true'], + ['true', 'true'], + [' true', ' true'], + [' 0 ', ' 0 '], + [1, '1'], + [1000, '1000'], + [-4356, '-4356'], + [0, '0'], + [0.001, '0.001'], + + // Tricky cases + [0.0, '0'], + [NaN, 'NaN'], + [Infinity, 'Infinity'], + [-Infinity, '-Infinity'] + ])('should correctly convert %s to string %s', (v, ev: string ) => { + const bool = valueConverters.toStringConverter(v) + expect(bool).toBe(ev) + }) + it.each([ + undefined, + ['a','b'], // metadata value in Obsidian can be also an array + ])('should not convert %s to string', (v) => { + const bool = valueConverters.toStringConverter(v) + expect(bool).toBeUndefined() + }) +}) + +describe('toFloatConverter', () => { + let valueConverters: ValueConverters + beforeAll(() => { + valueConverters = new ValueConverters() + }) + it.each([ + [' 0 ', 0], + [1, 1], + [1000, 1000], + [-4356, -4356], + [0, 0], + [0.001, 0.001], + [1E+3, 1000], + ['-1E+3', -1000], + ['1E-5', 0.00001], + + // Tricky cases + [0.0, 0], + [true, 1], + [false, 0], + ['0.1.2.3', 0.1], + ['10 .1.2.3', 10], + ['567abc', 567], + ['567.890abc', 567.89], + ])('should correctly convert %s to float %s', (v, ev: number ) => { + const float = valueConverters.toFloatConverter(v) + expect(float).toBe(ev) + }) + it.each([ + '', + ' ', + undefined, + NaN, + Infinity, + -Infinity, + '-', + '.', + '-.', + ['a','b'], // metadata value in Obsidian can be also an array + ])('should not convert %s to float', (v) => { + const float = valueConverters.toFloatConverter(v) + expect(float).toBeUndefined() + }) +}) + +describe('toIntConverter', () => { + let valueConverters: ValueConverters + beforeAll(() => { + valueConverters = new ValueConverters() + }) + it.each([ + [' 0 ', 0], + [1, 1], + [1000, 1000], + [-4356, -4356], + [0, 0], + [0.001, 0], + [1E+3, 1000], + ['-1E+3', -1000], + ['50E-5', 0], + + // Tricky cases + [0.0, 0], + [true, 1], + [false, 0], + ['0.1.2.3', 0], + ['10 .1.2.3', 10], + ['567abc', 567], + ['567.890abc', 567], + ])('should correctly convert %s to int %s', (v, ev: number ) => { + const int = valueConverters.toIntConverter(v) + expect(int).toBe(ev) + }) + it.each([ + '', + ' ', + undefined, + NaN, + Infinity, + -Infinity, + '-', + '.', + '-.', + ['a','b'], // metadata value in Obsidian can be also an array + ])('should not convert %s to int', (v) => { + const int = valueConverters.toIntConverter(v) + expect(int).toBeUndefined() + }) +}) + +