feat: add Jest configuration and update dependencies for TypeScript

This commit is contained in:
Riosk It 2025-06-25 21:53:15 +09:00
parent 00596bcec5
commit f587b3543f
9 changed files with 5094 additions and 84 deletions

15
jest.config.js Normal file
View file

@ -0,0 +1,15 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/src'],
testMatch: ['**/__tests__/**/*.test.ts'],
transform: {
'^.+\\.ts$': 'ts-jest'
},
collectCoverageFrom: [
'src/**/*.ts',
'!src/**/*.d.ts',
'!src/**/__tests__/**',
'!src/main.ts'
]
};

4824
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -6,9 +6,15 @@
"scripts": {
"dev": "node esbuild.config.mjs",
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
"version": "node version-bump.mjs && git add manifest.json versions.json"
"version": "node version-bump.mjs && git add manifest.json versions.json",
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"
},
"keywords": ["todo.txt", "task"],
"keywords": [
"todo.txt",
"task"
],
"author": "Riosk It",
"license": "MIT",
"repository": {
@ -16,12 +22,15 @@
"url": "https://github.com/rioskit/obsidian-todo-txt-mode"
},
"devDependencies": {
"@types/jest": "^30.0.0",
"@types/node": "^16.11.6",
"@typescript-eslint/eslint-plugin": "5.29.0",
"@typescript-eslint/parser": "5.29.0",
"builtin-modules": "3.3.0",
"esbuild": "0.17.3",
"jest": "^29.7.0",
"obsidian": "latest",
"ts-jest": "^29.4.0",
"tslib": "2.4.0",
"typescript": "4.7.4"
}

View file

@ -0,0 +1,80 @@
import { parsePriorityValue, parseProjectTag, parseContextTag, parseDueDate } from '../parser';
describe('TodoTxtSorter - 実装関数のテスト', () => {
describe('parsePriorityValue', () => {
it('完了タスクは最大値を返す', () => {
expect(parsePriorityValue('x Completed task')).toBe(Number.MAX_SAFE_INTEGER);
expect(parsePriorityValue(' x Completed task with spaces')).toBe(Number.MAX_SAFE_INTEGER);
});
it('優先度なしは最大値-1を返す', () => {
expect(parsePriorityValue('Task without priority')).toBe(Number.MAX_SAFE_INTEGER - 1);
});
it('アルファベット優先度の処理', () => {
expect(parsePriorityValue('(A) High priority')).toBe(0);
expect(parsePriorityValue('(B) Medium priority')).toBe(1);
expect(parsePriorityValue('(C) Low priority')).toBe(2);
expect(parsePriorityValue('(Z) Lowest priority')).toBe(25);
});
it('数値優先度の処理', () => {
expect(parsePriorityValue('(1) First task')).toBe(1);
expect(parsePriorityValue('(10) Tenth task')).toBe(10);
expect(parsePriorityValue('(123) Task 123')).toBe(123);
});
it('混合優先度の処理', () => {
expect(parsePriorityValue('(1a) Mixed priority')).toBe(1);
expect(parsePriorityValue('(10b) Another mixed')).toBe(10);
expect(parsePriorityValue('(A1) Letter first')).toBe(0);
});
});
describe('parseProjectTag', () => {
it('プロジェクトタグを抽出', () => {
expect(parseProjectTag('Task +project1')).toBe('project1');
expect(parseProjectTag('Task +Project2 with more text')).toBe('project2');
expect(parseProjectTag('+ProjectAtStart')).toBe('projectatstart');
});
it('プロジェクトタグがない場合のデフォルト値', () => {
expect(parseProjectTag('Task without project')).toBe('zzzz');
});
it('複数のプロジェクトタグがある場合は最初のものを返す', () => {
expect(parseProjectTag('Task +first +second')).toBe('first');
});
});
describe('parseContextTag', () => {
it('コンテキストタグを抽出', () => {
expect(parseContextTag('Task @home')).toBe('home');
expect(parseContextTag('Task @Work with more text')).toBe('work');
expect(parseContextTag('@ContextAtStart')).toBe('contextatstart');
});
it('コンテキストタグがない場合のデフォルト値', () => {
expect(parseContextTag('Task without context')).toBe('zzzz');
});
it('複数のコンテキストタグがある場合は最初のものを返す', () => {
expect(parseContextTag('Task @first @second')).toBe('first');
});
});
describe('parseDueDate', () => {
it('期日を抽出', () => {
expect(parseDueDate('Task due:2023-12-31')).toBe('2023-12-31');
expect(parseDueDate('due:2024-01-01 Task with date')).toBe('2024-01-01');
});
it('期日なしの場合のデフォルト値(未来)', () => {
expect(parseDueDate('Task without due date')).toBe('9999-99-99');
});
it('期日なしの場合のデフォルト値(過去)', () => {
expect(parseDueDate('Task without due date', false)).toBe('0000-00-00');
});
});
});

View file

@ -0,0 +1,140 @@
import { projectRegex, contextRegex, priorityRegex, dueDateRegex } from '../syntax';
describe('Todo.txt Syntax - 実装正規表現のテスト', () => {
describe('projectRegex', () => {
beforeEach(() => {
// グローバル正規表現のlastIndexをリセット
projectRegex.lastIndex = 0;
});
it('プロジェクトタグを正しくマッチ', () => {
expect('+project'.match(projectRegex)).toEqual(['+project']);
projectRegex.lastIndex = 0;
expect('Task +project1 +project2'.match(projectRegex)).toEqual(['+project1', '+project2']);
projectRegex.lastIndex = 0;
expect('+ProjectAtStart and +ProjectAtEnd'.match(projectRegex)).toEqual(['+ProjectAtStart', '+ProjectAtEnd']);
});
it('プロジェクトタグがない場合はnull', () => {
expect('No project here'.match(projectRegex)).toBeNull();
projectRegex.lastIndex = 0;
expect('+ space after plus'.match(projectRegex)).toBeNull();
});
it('特殊文字を含むプロジェクトタグ', () => {
expect('Task +project-name'.match(projectRegex)).toEqual(['+project-name']);
projectRegex.lastIndex = 0;
expect('Task +project_name'.match(projectRegex)).toEqual(['+project_name']);
projectRegex.lastIndex = 0;
expect('Task +project123'.match(projectRegex)).toEqual(['+project123']);
});
});
describe('contextRegex', () => {
beforeEach(() => {
contextRegex.lastIndex = 0;
});
it('コンテキストタグを正しくマッチ', () => {
expect('@home'.match(contextRegex)).toEqual(['@home']);
contextRegex.lastIndex = 0;
expect('Task @context1 @context2'.match(contextRegex)).toEqual(['@context1', '@context2']);
contextRegex.lastIndex = 0;
expect('@ContextAtStart and @ContextAtEnd'.match(contextRegex)).toEqual(['@ContextAtStart', '@ContextAtEnd']);
});
it('コンテキストタグがない場合はnull', () => {
expect('No context here'.match(contextRegex)).toBeNull();
contextRegex.lastIndex = 0;
expect('@ space after at'.match(contextRegex)).toBeNull();
});
it('特殊文字を含むコンテキストタグ', () => {
expect('Task @context-name'.match(contextRegex)).toEqual(['@context-name']);
contextRegex.lastIndex = 0;
expect('Task @context_name'.match(contextRegex)).toEqual(['@context_name']);
contextRegex.lastIndex = 0;
expect('Task @context123'.match(contextRegex)).toEqual(['@context123']);
});
});
describe('priorityRegex', () => {
it('アルファベット優先度をマッチ', () => {
expect('(A) Task'.match(priorityRegex)).toBeTruthy();
expect('(B) Task'.match(priorityRegex)).toBeTruthy();
expect('(Z) Task'.match(priorityRegex)).toBeTruthy();
});
it('数値優先度をマッチ', () => {
expect('(1) Task'.match(priorityRegex)).toBeTruthy();
expect('(10) Task'.match(priorityRegex)).toBeTruthy();
expect('(123) Task'.match(priorityRegex)).toBeTruthy();
});
it('混合優先度をマッチ', () => {
expect('(A1) Task'.match(priorityRegex)).toBeTruthy();
expect('(1a) Task'.match(priorityRegex)).toBeTruthy();
expect('(A1b2) Task'.match(priorityRegex)).toBeTruthy();
});
it('スペースありでもマッチ', () => {
expect(' (A) Task with spaces'.match(priorityRegex)).toBeTruthy();
expect(' (B) Another task'.match(priorityRegex)).toBeTruthy();
});
it('完了タスクでもマッチ', () => {
expect('x (A) Completed task'.match(priorityRegex)).toBeTruthy();
expect(' x (B) Completed with spaces'.match(priorityRegex)).toBeTruthy();
});
it('無効な優先度はマッチしない', () => {
expect('(a) Lower case'.match(priorityRegex)).toBeNull();
expect('No priority here'.match(priorityRegex)).toBeNull();
expect('Middle (A) priority'.match(priorityRegex)).toBeNull();
});
});
describe('dueDateRegex', () => {
beforeEach(() => {
dueDateRegex.lastIndex = 0;
});
it('標準的な期日フォーマットをマッチ', () => {
expect('due:2024-12-31'.match(dueDateRegex)).toEqual(['due:2024-12-31']);
dueDateRegex.lastIndex = 0;
expect('Task due:2024-01-01 text'.match(dueDateRegex)).toEqual(['due:2024-01-01']);
});
it('様々な期日フォーマットをマッチ', () => {
expect('due:tomorrow'.match(dueDateRegex)).toEqual(['due:tomorrow']);
dueDateRegex.lastIndex = 0;
expect('due:today'.match(dueDateRegex)).toEqual(['due:today']);
dueDateRegex.lastIndex = 0;
expect('due:2024/12/31'.match(dueDateRegex)).toEqual(['due:2024/12/31']);
});
it('複数の期日をマッチ', () => {
expect('Task due:2024-01-01 and due:2024-12-31'.match(dueDateRegex))
.toEqual(['due:2024-01-01', 'due:2024-12-31']);
});
it('期日がない場合はnull', () => {
expect('No due date here'.match(dueDateRegex)).toBeNull();
dueDateRegex.lastIndex = 0;
expect('due: space after colon'.match(dueDateRegex)).toBeNull();
});
});
describe('完了タスク判定', () => {
it('完了タスクを正しく判定', () => {
expect('x Completed task'.trim().startsWith('x ')).toBe(true);
expect('x 2024-01-01 Completed task with date'.trim().startsWith('x ')).toBe(true);
});
it('未完了タスクは判定しない', () => {
expect('Active task'.trim().startsWith('x ')).toBe(false);
expect('(A) Priority task'.trim().startsWith('x ')).toBe(false);
expect('Task with x in middle'.trim().startsWith('x ')).toBe(false);
});
});
});

44
src/parser.ts Normal file
View file

@ -0,0 +1,44 @@
// Todo.txt形式の純粋なパーサー関数Obsidianに依存しない
export function parsePriorityValue(line: string): number {
if (line.trim().startsWith('x ')) {
return Number.MAX_SAFE_INTEGER;
}
const priorityMatch = line.match(/^\s*\(([A-Z0-9][A-Z0-9a-z0-9]*)\)/);
if (!priorityMatch) {
return Number.MAX_SAFE_INTEGER - 1;
}
const priority = priorityMatch[1];
if (/^\d+$/.test(priority)) {
return parseInt(priority, 10);
}
if (/^\d/.test(priority)) {
const numericPart = priority.match(/^\d+/);
if (numericPart) {
return parseInt(numericPart[0], 10);
}
}
return priority.charCodeAt(0) - 'A'.charCodeAt(0);
}
export function parseProjectTag(line: string): string {
const projectMatch = line.match(/\+([^\s]+)/);
return projectMatch ? projectMatch[1].toLowerCase() : 'zzzz';
}
export function parseContextTag(line: string): string {
const contextMatch = line.match(/@([^\s]+)/);
return contextMatch ? contextMatch[1].toLowerCase() : 'zzzz';
}
export function parseDueDate(line: string, defaultToFuture: boolean = true): string {
const dueDateMatch = line.match(/due:(\d{4}-\d{2}-\d{2})/);
if (!dueDateMatch) {
return defaultToFuture ? '9999-99-99' : '0000-00-00';
}
return dueDateMatch[1];
}

View file

@ -1,5 +1,6 @@
import { App, MarkdownView, Notice, Plugin, TFile, normalizePath } from 'obsidian';
import { SortType, TodoTxtSettings } from './settings';
import { parsePriorityValue, parseProjectTag, parseContextTag, parseDueDate } from './parser';
export class TodoTxtSorter {
constructor(
@ -9,51 +10,19 @@ export class TodoTxtSorter {
) {}
getPriorityValue(line: string): number {
if (line.trim().startsWith('x ')) {
return Number.MAX_SAFE_INTEGER;
}
const priorityMatch = line.match(/^\s*\(([A-Z0-9][A-Z0-9a-z0-9]*)\)/);
if (!priorityMatch) {
return Number.MAX_SAFE_INTEGER - 1; // 優先度なしは完了タスクの上
}
const priority = priorityMatch[1];
// 数字だけの場合はその値を返す
if (/^\d+$/.test(priority)) {
return parseInt(priority, 10);
}
// 先頭が数字の場合は数値として解釈
if (/^\d/.test(priority)) {
const numericPart = priority.match(/^\d+/);
if (numericPart) {
return parseInt(numericPart[0], 10);
}
}
// 先頭がアルファベットの場合は順序に変換A=0, B=1, ...
// 複数のアルファベットの場合は先頭文字のみを考慮
return priority.charCodeAt(0) - 'A'.charCodeAt(0);
return parsePriorityValue(line);
}
getProjectTag(line: string): string {
const projectMatch = line.match(/\+([^\s]+)/);
return projectMatch ? projectMatch[1].toLowerCase() : 'zzzz'; // プロジェクトなしは最後
return parseProjectTag(line);
}
getContextTag(line: string): string {
const contextMatch = line.match(/@([^\s]+)/);
return contextMatch ? contextMatch[1].toLowerCase() : 'zzzz'; // コンテキストなしは最後
return parseContextTag(line);
}
getDueDate(line: string, defaultToFuture: boolean = true): string {
const dueDateMatch = line.match(/due:(\d{4}-\d{2}-\d{2})/);
// 期日なしの場合、ソートの種類によって異なる値を返す
if (!dueDateMatch) {
return defaultToFuture ? '9999-99-99' : '0000-00-00'; // ソート種別によって最初か最後に
}
return dueDateMatch[1];
return parseDueDate(line, defaultToFuture);
}
registerSortCommands(plugin: Plugin) {

View file

@ -3,12 +3,14 @@ import { RangeSetBuilder } from '@codemirror/state';
import { App, TFile } from 'obsidian';
import { TodoTxtSettings } from './settings';
// 正規表現をテスト用にexport
export const projectRegex = /\+[^\s]+/g;
export const contextRegex = /@[^\s]+/g;
export const priorityRegex = /^(\s*(?:x\s+)?\s*)\(([A-Z0-9][A-Z0-9a-z0-9]*)\)/;
export const dueDateRegex = /due:[^\s]+/g;
export function createTodoTxtExtension(app: App, isTodoTxtFile: (path: string) => boolean, getSettings: () => TodoTxtSettings) {
const projectRegex = /\+[^\s]+/g;
const contextRegex = /@[^\s]+/g;
const priorityRegex = /^(\s*(?:x\s+)?\s*)\(([A-Z0-9][A-Z0-9a-z0-9]*)\)/;
// const dueDateRegex = /due:(\d{4}-\d{2}-\d{2})/g;
const dueDateRegex = /due:[^\s]+/g;
// 正規表現はモジュールレベルで定義されたものを使用
return ViewPlugin.fromClass(class implements PluginValue {
decorations: DecorationSet;

9
tsconfig.test.json Normal file
View file

@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"types": ["jest", "node"]
},
"include": [
"src/**/*.ts"
]
}