diff --git a/jest.config.js b/jest.config.js index a5205d27..60c1f487 100644 --- a/jest.config.js +++ b/jest.config.js @@ -124,7 +124,7 @@ module.exports = { // setupFiles: [], // A list of paths to modules that run some code to configure or set up the testing framework before each test - // setupFilesAfterEnv: [], + setupFilesAfterEnv: ["jest-extended"], // A list of paths to snapshot serializer modules Jest should use for snapshot testing // snapshotSerializers: [], diff --git a/package-lock.json b/package-lock.json index b5423f25..212a3bc7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6437,6 +6437,155 @@ } } }, + "jest-extended": { + "version": "0.11.5", + "resolved": "https://registry.npmjs.org/jest-extended/-/jest-extended-0.11.5.tgz", + "integrity": "sha512-3RsdFpLWKScpsLD6hJuyr/tV5iFOrw7v6YjA3tPdda9sJwoHwcMROws5gwiIZfcwhHlJRwFJB2OUvGmF3evV/Q==", + "dev": true, + "requires": { + "expect": "^24.1.0", + "jest-get-type": "^22.4.3", + "jest-matcher-utils": "^22.0.0" + }, + "dependencies": { + "@jest/console": { + "version": "24.9.0", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-24.9.0.tgz", + "integrity": "sha512-Zuj6b8TnKXi3q4ymac8EQfc3ea/uhLeCGThFqXeC8H9/raaH8ARPUTdId+XyGd03Z4In0/VjD2OYFcBF09fNLQ==", + "dev": true, + "requires": { + "@jest/source-map": "^24.9.0", + "chalk": "^2.0.1", + "slash": "^2.0.0" + } + }, + "@jest/source-map": { + "version": "24.9.0", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-24.9.0.tgz", + "integrity": "sha512-/Xw7xGlsZb4MJzNDgB7PW5crou5JqWiBQaz6xyPd3ArOg2nfn/PunV8+olXbbEZzNl591o5rWKE9BRDaFAuIBg==", + "dev": true, + "requires": { + "callsites": "^3.0.0", + "graceful-fs": "^4.1.15", + "source-map": "^0.6.0" + } + }, + "@jest/test-result": { + "version": "24.9.0", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-24.9.0.tgz", + "integrity": "sha512-XEFrHbBonBJ8dGp2JmF8kP/nQI/ImPpygKHwQ/SY+es59Z3L5PI4Qb9TQQMAEeYsThG1xF0k6tmG0tIKATNiiA==", + "dev": true, + "requires": { + "@jest/console": "^24.9.0", + "@jest/types": "^24.9.0", + "@types/istanbul-lib-coverage": "^2.0.0" + } + }, + "ansi-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz", + "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=", + "dev": true + }, + "expect": { + "version": "24.9.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-24.9.0.tgz", + "integrity": "sha512-wvVAx8XIol3Z5m9zvZXiyZOQ+sRJqNTIm6sGjdWlaZIeupQGO3WbYI+15D/AmEwZywL6wtJkbAbJtzkOfBuR0Q==", + "dev": true, + "requires": { + "@jest/types": "^24.9.0", + "ansi-styles": "^3.2.0", + "jest-get-type": "^24.9.0", + "jest-matcher-utils": "^24.9.0", + "jest-message-util": "^24.9.0", + "jest-regex-util": "^24.9.0" + }, + "dependencies": { + "jest-get-type": { + "version": "24.9.0", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-24.9.0.tgz", + "integrity": "sha512-lUseMzAley4LhIcpSP9Jf+fTrQ4a1yHQwLNeeVa2cEmbCGeoZAtYPOIv8JaxLD/sUpKxetKGP+gsHl8f8TSj8Q==", + "dev": true + }, + "jest-matcher-utils": { + "version": "24.9.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-24.9.0.tgz", + "integrity": "sha512-OZz2IXsu6eaiMAwe67c1T+5tUAtQyQx27/EMEkbFAGiw52tB9em+uGbzpcgYVpA8wl0hlxKPZxrly4CXU/GjHA==", + "dev": true, + "requires": { + "chalk": "^2.0.1", + "jest-diff": "^24.9.0", + "jest-get-type": "^24.9.0", + "pretty-format": "^24.9.0" + } + } + } + }, + "jest-get-type": { + "version": "22.4.3", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-22.4.3.tgz", + "integrity": "sha512-/jsz0Y+V29w1chdXVygEKSz2nBoHoYqNShPe+QgxSNjAuP1i8+k4LbQNrfoliKej0P45sivkSCh7yiD6ubHS3w==", + "dev": true + }, + "jest-matcher-utils": { + "version": "22.4.3", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-22.4.3.tgz", + "integrity": "sha512-lsEHVaTnKzdAPR5t4B6OcxXo9Vy4K+kRRbG5gtddY8lBEC+Mlpvm1CJcsMESRjzUhzkz568exMV1hTB76nAKbA==", + "dev": true, + "requires": { + "chalk": "^2.0.1", + "jest-get-type": "^22.4.3", + "pretty-format": "^22.4.3" + }, + "dependencies": { + "pretty-format": { + "version": "22.4.3", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-22.4.3.tgz", + "integrity": "sha512-S4oT9/sT6MN7/3COoOy+ZJeA92VmOnveLHgrwBE3Z1W5N9S2A1QGNYiE1z75DAENbJrXXUb+OWXhpJcg05QKQQ==", + "dev": true, + "requires": { + "ansi-regex": "^3.0.0", + "ansi-styles": "^3.2.0" + } + } + } + }, + "jest-message-util": { + "version": "24.9.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-24.9.0.tgz", + "integrity": "sha512-oCj8FiZ3U0hTP4aSui87P4L4jC37BtQwUMqk+zk/b11FR19BJDeZsZAvIHutWnmtw7r85UmR3CEWZ0HWU2mAlw==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.0.0", + "@jest/test-result": "^24.9.0", + "@jest/types": "^24.9.0", + "@types/stack-utils": "^1.0.1", + "chalk": "^2.0.1", + "micromatch": "^3.1.10", + "slash": "^2.0.0", + "stack-utils": "^1.0.1" + } + }, + "jest-regex-util": { + "version": "24.9.0", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-24.9.0.tgz", + "integrity": "sha512-05Cmb6CuxaA+Ys6fjr3PhvV3bGQmO+2p2La4hFbU+W5uOc479f7FdLXUWXw4pYMAhhSZIuKHwSXSu6CsSBAXQA==", + "dev": true + }, + "slash": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz", + "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==", + "dev": true + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } + } + }, "jest-get-type": { "version": "24.9.0", "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-24.9.0.tgz", diff --git a/package.json b/package.json index 4810ccad..a20ad132 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "cross-env": "^6.0.3", "html-webpack-plugin": "^3.2.0", "jest": "^25.1.0", + "jest-extended": "^0.11.5", "react-test-renderer": "^16.12.0", "ts-jest": "^25.0.0", "tslint": "^6.0.0", diff --git a/src/ts/contentScripts/estimates/index.ts b/src/ts/contentScripts/estimates/index.ts new file mode 100644 index 00000000..4e989b95 --- /dev/null +++ b/src/ts/contentScripts/estimates/index.ts @@ -0,0 +1,54 @@ +import {Roam, RoamNode} from '../../utils/roam'; +import {Feature, Settings, Shortcut, String} from '../../utils/settings'; +import {getActiveEditElement} from '../../utils/dom'; + +const estimateProperty: String = {type: 'string', id: 'estimate_property', label: 'Property to base estimates on'}; + +export const config: Feature = { + id: 'calculate-estimate', + name: 'Calculate estimate', + settings: [ + { + type: 'shortcut', + id: 'calculate-estimate', + label: 'Calculate estimate shortcut', + initValue: 'ctrl+m', + placeholder: '', + onPress: calculateFirstSiblingTotal + } as Shortcut, + estimateProperty, + ], +}; + +function getParentElement() { + return getActiveEditElement()?.closest('.roam-block-container')?.parentElement; +} + +/** I'm still figuring out UX on this one. + * The current expectation is that you have to create a parent node, put a query as a child of it, + * then run this with cursor ina query sibling. + * Maybe I should create sibling? then you can do it from query node, which seems somewhat more intuitive + * but when your cursor is in the query node it's not rendered, which may be confusing + * + * or maybe flow - you select query node, then press shortcut, get estimate for it in new node below + * + */ +export async function calculateFirstSiblingTotal() { + const attributeName = await Settings.get(config.id, estimateProperty.id, 'pomodoro_estimate'); + const estimateRegex = new RegExp(`${attributeName}:\\s*(\\d+\\.?\\d*)`, 'g'); + + const queryNode = getParentElement()?.querySelector('.rm-reference-main') as HTMLElement; + const queryText = queryNode?.innerText; + console.log('Extracting estimate from ' + queryText); + + let total = 0; + + const nextMatch = () => estimateRegex.exec(queryText); + let match = nextMatch(); + while (match) { + total += parseFloat(match[1]); + match = nextMatch(); + } + + Roam.applyToCurrent(node => new RoamNode(`total_${attributeName}::${total}` + node.text, node.selection)) +} \ No newline at end of file diff --git a/src/ts/contentScripts/features.tsx b/src/ts/contentScripts/features.ts similarity index 95% rename from src/ts/contentScripts/features.tsx rename to src/ts/contentScripts/features.ts index 2eb2d1f8..be76e307 100644 --- a/src/ts/contentScripts/features.tsx +++ b/src/ts/contentScripts/features.ts @@ -4,6 +4,7 @@ import {config as incDec} from './inc-dec-value/index' import {config as customCss} from './custom-css/index' import {config as srs} from './srs/index' import {config as blockManipulation} from './block-manipulation' +import {config as estimate} from './estimates/index' import {filterAsync, mapAsync} from '../utils/async'; export const Features = { @@ -12,6 +13,7 @@ export const Features = { customCss, srs, blockManipulation, + estimate, ]), isActive: Settings.isActive, diff --git a/src/ts/contentScripts/inc-dec-value/index.tsx b/src/ts/contentScripts/inc-dec-value/index.tsx index bdcb5424..7dadd0d0 100644 --- a/src/ts/contentScripts/inc-dec-value/index.tsx +++ b/src/ts/contentScripts/inc-dec-value/index.tsx @@ -1,6 +1,6 @@ import {getActiveEditElement, getInputEvent} from '../../utils/dom'; import {Feature, Shortcut} from '../../utils/settings' -import {RoamDate} from '../../date/common'; +import {dateFromPageName, RoamDate} from '../../date/common'; export const config: Feature = { id: 'incDec', @@ -23,16 +23,6 @@ export const config: Feature = { ] } - -const dateFromPageName = (text: string): Date => { - return new Date( - text - .slice(2) - .slice(0, -2) - .replace(/(th,|nd,|rd,|st,)/, ',') - ); -}; - const saveChanges = (el: HTMLTextAreaElement, cursor: number, value: string): void => { el.value = value; el.selectionStart = cursor; diff --git a/src/ts/contentScripts/srs/index.tsx b/src/ts/contentScripts/srs/index.tsx index 81d2a592..dad49d8b 100644 --- a/src/ts/contentScripts/srs/index.tsx +++ b/src/ts/contentScripts/srs/index.tsx @@ -1,5 +1,8 @@ import {Roam, RoamNode} from '../../utils/roam'; import {Feature, Shortcut} from '../../utils/settings' +import {SRSSignal, SRSSignals} from '../../srs/scheduler'; +import {SM2Node} from '../../srs/SM2Node'; +import {AnkiScheduler} from '../../srs/AnkiScheduler'; export const config: Feature = { id: 'srs', @@ -12,16 +15,26 @@ export const config: Feature = { initValue: 'Ctrl+q', onPress: triggerNextBucket } as Shortcut - ] + ].concat(SRSSignals.map(it => ({ + type: 'shortcut', id: `srs_${SRSSignal[it]}`, label: `SRS: ${SRSSignal[it]}`, initValue: `ctrl+shift+${it}`, + onPress: () => rescheduleCurrentNote(it) + } + ))) } -const bucketExpr = /\[\[Bucket (\d+)]]/; -const nextBucket = (nodeStr: string) => `[[Bucket ${parseInt(nodeStr) + 1}]]`; +export function rescheduleCurrentNote(signal: SRSSignal) { + const scheduler = new AnkiScheduler() + Roam.applyToCurrent(node => scheduler.schedule(new SM2Node(node.text, node.selection), signal)) +} + +const bucketExpr = /(?:\[\[\[\[interval]]::(\d+)]])|(?:#Box(\d+))/gi; +const nextBucket = (nodeStr: string) => `[[[[interval]]::${parseInt(nodeStr) + 1}]]`; export function triggerNextBucket() { Roam.applyToCurrent( - (element => { - return new RoamNode(element.text.replace(bucketExpr, (_, numStr: string) => nextBucket(numStr)), - element.selection); - })); -} + (element => + new RoamNode( + element.text.replace(bucketExpr, + (_, ...numbers) => nextBucket(numbers.filter(it => it)[0])), + element.selection))); +} \ No newline at end of file diff --git a/src/ts/date/common.ts b/src/ts/date/common.ts index e76cc807..4cc1550f 100644 --- a/src/ts/date/common.ts +++ b/src/ts/date/common.ts @@ -2,9 +2,24 @@ import dateFormat from 'dateformat'; export const RoamDate = { formatString: `'[['mmmm dS, yyyy']]'`, - regex: /\[\[(January|February|March|April|May|June|July|August|September|October|November|December) \d{1,2}(st|nd|th|rd), \d{4}\]\]/gm, + regex: /\[\[(January|February|March|April|May|June|July|August|September|October|November|December) \d{1,2}(st|nd|th|rd), \d{4}]]/gm, format(date: Date) { return dateFormat(date, this.formatString) } } + +export const dateFromPageName = (text: string): Date => { + return new Date( + text + .slice(2) + .slice(0, -2) + .replace(/(th,|nd,|rd,|st,)/, ',') + ); +}; + +export function addDays(date: Date, days: number) { + const result = new Date(date) + result.setDate(result.getDate() + days) + return result +} \ No newline at end of file diff --git a/src/ts/date/withDate.ts b/src/ts/date/withDate.ts new file mode 100644 index 00000000..4fb11853 --- /dev/null +++ b/src/ts/date/withDate.ts @@ -0,0 +1,27 @@ +import {RoamNode} from '../utils/roam'; +import {dateFromPageName, RoamDate} from './common'; +import {Constructor} from '../mixins/common'; + +export function withDate>(SuperClass: T) { + return class NodeWithDate extends SuperClass { + listDatePages() { + return this.text.match(RoamDate.regex) || [] + } + + listDates() { + return this.listDatePages().map(dateFromPageName) + } + + /** If has 1 date - replace it, if more then 1 date - append it */ + withDate(date: Date) { + const currentDates = this.listDatePages() + const newDate = RoamDate.format(date); + const newText = currentDates.length === 1 ? + this.text.replace(currentDates[0], newDate) : + this.text + ' ' + newDate; + + // @ts-ignore + return new this.constructor(newText, this.selection) + } + }; +} \ No newline at end of file diff --git a/src/ts/mixins/common.ts b/src/ts/mixins/common.ts new file mode 100644 index 00000000..f758baa2 --- /dev/null +++ b/src/ts/mixins/common.ts @@ -0,0 +1 @@ +export type Constructor = new (...args: any[]) => T; diff --git a/src/ts/srs/AnkiScheduler.ts b/src/ts/srs/AnkiScheduler.ts new file mode 100644 index 00000000..13e2ac14 --- /dev/null +++ b/src/ts/srs/AnkiScheduler.ts @@ -0,0 +1,88 @@ +import {SM2Node} from './SM2Node'; +import {addDays} from '../date/common'; +import {Scheduler, SRSSignal} from './scheduler'; + +/** + * Again (1) + * The card is placed into relearning mode, the ease is decreased by 20 percentage points + * (that is, 20 is subtracted from the ease value, which is in units of percentage points), and the current interval is + * multiplied by the value of new interval (this interval will be used when the card exits relearning mode). + * + * Hard (2) + * The card’s ease is decreased by 15 percentage points and the current interval is multiplied by 1.2. + * + * Good (3) + * The current interval is multiplied by the current ease. The ease is unchanged. + * + * Easy (4) + * The current interval is multiplied by the current ease times the easy bonus and the ease is + * increased by 15 percentage points. + * For Hard, Good, and Easy, the next interval is additionally multiplied by the interval modifier. + * If the card is being reviewed late, additional days will be added to the current interval, as described here. + * + * There are a few limitations on the scheduling values that cards can take. + * Eases will never be decreased below 130%; SuperMemo’s research has shown that eases below 130% tend to result in + * cards becoming due more often than is useful and annoying users. + * + * Source: https://docs.ankiweb.net/#/faqs?id=what-spaced-repetition-algorithm-does-anki-use + */ +export class AnkiScheduler implements Scheduler { + static defaultFactor = 2.5 + static defaultInterval = 2 + + static maxInterval = 50 * 365 + static minFactor = 1.3 + static hardFactor = 1.2; + + schedule(node: SM2Node, signal: SRSSignal): SM2Node { + const newParams = this.getNewParameters(node, signal); + + const currentDate = node.listDates()[0] || new Date() + return node + .withInterval(newParams.interval) + .withFactor(newParams.factor) + // TODO random jitter, in percentage points of interval + .withDate(addDays(currentDate, Math.ceil(newParams.interval))) + } + + + getNewParameters(node: SM2Node, signal: SRSSignal) { + const factor = node.factor || AnkiScheduler.defaultFactor + const interval = node.interval || AnkiScheduler.defaultInterval + + let newFactor = factor + let newInterval = interval + + const factorModifier = 0.15 + switch (signal) { + case SRSSignal.AGAIN: + newFactor = factor - 0.2 + newInterval = 1 + break + case SRSSignal.HARD: + newFactor = factor - factorModifier + newInterval = interval * AnkiScheduler.hardFactor + break + case SRSSignal.GOOD: + newInterval = interval * factor + break + case SRSSignal.EASY: + newInterval = interval * factor + newFactor = factor + factorModifier + break + } + + return AnkiScheduler.enforceLimits(new SM2Params(newInterval, newFactor)) + } + + private static enforceLimits(params: SM2Params) { + return new SM2Params( + Math.min(params.interval, AnkiScheduler.maxInterval), + Math.max(params.factor, AnkiScheduler.minFactor)) + } +} + +class SM2Params { + constructor(readonly interval: number, readonly factor: number) { + } +} \ No newline at end of file diff --git a/src/ts/srs/SM2Node.ts b/src/ts/srs/SM2Node.ts new file mode 100644 index 00000000..1fc52420 --- /dev/null +++ b/src/ts/srs/SM2Node.ts @@ -0,0 +1,30 @@ +import {RoamNode, Selection} from '../utils/roam'; +import {withDate} from '../date/withDate'; + + +export class SM2Node extends withDate(RoamNode) { + constructor(text: string, selection: Selection = new Selection()) { + super(text, selection); + } + + private readonly intervalProperty = 'interval'; + private readonly factorProperty = 'factor'; + + get interval(): number | undefined { + return parseFloat(this.getInlineProperty(this.intervalProperty)!); + } + + withInterval(interval: number): SM2Node { + // Discarding the fractional part for display purposes/and so we don't get infinite number of intervals + // Should potentially reconsider this later + return this.withInlineProperty(this.intervalProperty, Number(interval).toFixed(1)); + } + + get factor(): number | undefined { + return parseFloat(this.getInlineProperty(this.factorProperty)!); + } + + withFactor(factor: number): SM2Node { + return this.withInlineProperty(this.factorProperty, Number(factor).toFixed(2)); + } +} \ No newline at end of file diff --git a/src/ts/srs/scheduler.ts b/src/ts/srs/scheduler.ts new file mode 100644 index 00000000..e2fc3551 --- /dev/null +++ b/src/ts/srs/scheduler.ts @@ -0,0 +1,14 @@ +import {SM2Node} from './SM2Node'; + +export interface Scheduler { + schedule(node: SM2Node, signal: SRSSignal): SM2Node +} + +export enum SRSSignal { + AGAIN = 1, + HARD, + GOOD, + EASY, +} + +export const SRSSignals = [SRSSignal.AGAIN, SRSSignal.HARD, SRSSignal.GOOD, SRSSignal.EASY] \ No newline at end of file diff --git a/src/ts/utils/roam.ts b/src/ts/utils/roam.ts index 98551f7a..35465f25 100644 --- a/src/ts/utils/roam.ts +++ b/src/ts/utils/roam.ts @@ -130,7 +130,7 @@ export const Roam = { }; export class RoamNode { - constructor(readonly text: string, readonly selection: Selection) { + constructor(readonly text: string, readonly selection: Selection = new Selection()) { } textBeforeSelection() { @@ -158,9 +158,30 @@ export class RoamNode { new Selection(this.text.length, this.text.length) ) } + + getInlineProperty(name: string) { + return RoamNode.getInlinePropertyMatcher(name).exec(this.text)?.[1] + } + + withInlineProperty(name: string, value: string) { + const currentValue = this.getInlineProperty(name); + const property = RoamNode.createInlineProperty(name, value); + const newText = currentValue ? this.text.replace(RoamNode.getInlinePropertyMatcher(name), property) : + this.text + ' ' + property; + // @ts-ignore + return new this.constructor(newText, this.selection) + } + + static createInlineProperty(name: string, value: string) { + return `[[[[${name}]]::${value}]]` + } + + static getInlinePropertyMatcher(name: string) { + return new RegExp(`\\[\\[\\[\\[${name}]]::(.*?)]]`, 'g') + } } export class Selection { - constructor(readonly start: number, readonly end: number) { + constructor(readonly start: number = 0, readonly end: number = 0) { } -} \ No newline at end of file +} diff --git a/src/ts/utils/settings.ts b/src/ts/utils/settings.ts index 9e4da361..36e551d5 100644 --- a/src/ts/utils/settings.ts +++ b/src/ts/utils/settings.ts @@ -39,7 +39,8 @@ export interface Shortcut extends Setting { export const Settings = { - get: async (featureId: string, settingId: string) => (await getStateFromStorage())[featureId][settingId], + get: async (featureId: string, settingId: string, defaultValue?: string) => + (await getStateFromStorage())[featureId][settingId] || defaultValue, isActive: async (featureId: string) => (await getStateFromStorage())[featureId]?.active } diff --git a/tests/global.d.ts b/tests/global.d.ts new file mode 100644 index 00000000..b3d6c479 --- /dev/null +++ b/tests/global.d.ts @@ -0,0 +1 @@ +import 'jest-extended'; \ No newline at end of file diff --git a/tests/ts/date/withDate.test.ts b/tests/ts/date/withDate.test.ts new file mode 100644 index 00000000..13a0e1e3 --- /dev/null +++ b/tests/ts/date/withDate.test.ts @@ -0,0 +1,44 @@ +import {withDate} from '../../../src/ts/date/withDate'; +import {RoamNode} from '../../../src/ts/utils/roam'; +import {RoamDate} from '../../../src/ts/date/common'; + +const NodeWitDate = withDate(RoamNode) + +describe(NodeWitDate, () => { + const datePage1 = `[[February 23rd, 2020]]`; + const datePage2 = `[[February 24th, 2020]]`; + + const multiDateNode = new NodeWitDate(`test ${datePage1} ${datePage2}`) + const noDateNode = new NodeWitDate('test') + + describe('listDatePages', () => { + test('return list of dates when multiple are present', () => { + expect(multiDateNode.listDatePages()).toStrictEqual([datePage1, datePage2]) + }) + + test('return empty array when no dates found', () => { + expect(noDateNode.listDatePages()).toHaveLength(0) + }) + }) + + describe('withDate', () => { + const newDate = new Date(2020, 2, 22); + test('when multiple dates => append new one at the end', () => { + expect(multiDateNode.withDate(newDate).text).toEndWith(RoamDate.format(newDate)) + }) + + test('when no date => append new one at the end', () => { + expect(noDateNode.withDate(newDate).text).toEndWith(RoamDate.format(newDate)) + }) + + test('when 1 date => replace it with new one', () => { + const oneDateNode = new NodeWitDate(`test ${datePage1} continues`) + + const newNode = oneDateNode.withDate(newDate); + const dateStr = RoamDate.format(newDate); + + expect(newNode.text).not.toEndWith(dateStr) + expect(newNode.listDatePages()).toContain(dateStr) + }) + }) +}) \ No newline at end of file diff --git a/tests/ts/roam/RoamNode.test.ts b/tests/ts/roam/RoamNode.test.ts new file mode 100644 index 00000000..96f660c8 --- /dev/null +++ b/tests/ts/roam/RoamNode.test.ts @@ -0,0 +1,56 @@ +import {RoamNode} from '../../../src/ts/utils/roam'; + +describe(RoamNode, () => { + const propertyName = 'inline_property'; + const dateProperty = 'date_property'; + const value = `value`; + + const propertyMatcher = RoamNode.getInlinePropertyMatcher(propertyName); + const nodeWithValue = new RoamNode(`blah [[[[${propertyName}]]::${value}]]`); + const nodeWithNoProperty = new RoamNode(`blah`); + + const datePage = '[[February 26th, 2020]]'; + const nodeWithDate = new RoamNode(`[[[[${dateProperty}]]::${datePage}]]`) + + describe('getInlineProperty', () => { + test('retrieves inline property if it is present', () => { + expect(nodeWithValue.getInlineProperty(propertyName)).toBe(value); + }); + + test('returns emptystring for empty property value', () => { + const testNode = new RoamNode(`blah [[[[${propertyName}]]::]]`); + + expect(testNode.getInlineProperty(propertyName)).toBe(''); + }); + + test('returns undefined when property is missing', () => { + expect(nodeWithNoProperty.getInlineProperty(propertyName)).toBeUndefined(); + }) + + test('works for values with multiple properties', () => { + expect(new RoamNode(nodeWithValue.text+nodeWithValue.text) + .getInlineProperty(propertyName)).toBe(value) + }) + + // TODO + test.skip('works for values containing brackets', () => { + expect(nodeWithDate.getInlineProperty(dateProperty)).toBe(datePage) + }) + + }); + + describe('withInlineProperty', () => { + test('if missing would append at the end', () => { + expect(nodeWithNoProperty.withInlineProperty(propertyName, value).text) + .toMatch(propertyMatcher) + }); + + test('if present replace current value with new one', () => { + const newValue = 'newValue'; + const resultNode = nodeWithValue.withInlineProperty(propertyName, newValue); + + expect(resultNode.text.match(propertyMatcher)).toHaveLength(1); + expect(resultNode.getInlineProperty(propertyName)).toBe(newValue); + }) + }); +}); \ No newline at end of file diff --git a/tests/ts/srs/AnkiScheduler.test.ts b/tests/ts/srs/AnkiScheduler.test.ts new file mode 100644 index 00000000..86096916 --- /dev/null +++ b/tests/ts/srs/AnkiScheduler.test.ts @@ -0,0 +1,28 @@ +import {SRSSignal} from '../../../src/ts/srs/scheduler'; +import {SM2Node} from '../../../src/ts/srs/SM2Node'; +import {AnkiScheduler} from '../../../src/ts/srs/AnkiScheduler'; + +describe(AnkiScheduler, () => { + const subject = new AnkiScheduler() + const unscheduledNode = new SM2Node('empty') + + test('on good, current interval multiplied by current factor', () => { + const testNode = new SM2Node('blah ').withInterval(5).withFactor(2) + + expect(subject.schedule(testNode, SRSSignal.GOOD).interval).toBe(10) + }) + + test('no scheduling info - schedule with default values', () => { + const rescheduledNode = subject.schedule(unscheduledNode, SRSSignal.GOOD) + + expect(rescheduledNode.interval).toBe(AnkiScheduler.defaultInterval * AnkiScheduler.defaultFactor) + expect(rescheduledNode.factor).toBe(AnkiScheduler.defaultFactor) + expect(rescheduledNode.listDatePages).not.toBeEmpty() + }) + + // TODO + test('again', () => {}) + test('hard', () => {}) + test('easy', () => {}) + test('limits', () => {}) +}) \ No newline at end of file