shawnxixi-cli 0.3.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/.env.example +8 -2
  2. package/.github/workflows/ci.yml +9 -22
  3. package/.github/workflows/release.yml +30 -0
  4. package/CLAUDE.md +167 -63
  5. package/README.md +345 -65
  6. package/commitlint.config.js +10 -0
  7. package/dist/commands/biz/calendar.d.ts +17 -2
  8. package/dist/commands/biz/calendar.d.ts.map +1 -1
  9. package/dist/commands/biz/calendar.js +47 -9
  10. package/dist/commands/biz/calendar.js.map +1 -1
  11. package/dist/commands/biz/finance.d.ts +32 -1
  12. package/dist/commands/biz/finance.d.ts.map +1 -1
  13. package/dist/commands/biz/finance.js +99 -12
  14. package/dist/commands/biz/finance.js.map +1 -1
  15. package/dist/commands/biz/health.d.ts +32 -0
  16. package/dist/commands/biz/health.d.ts.map +1 -0
  17. package/dist/commands/biz/health.js +92 -0
  18. package/dist/commands/biz/health.js.map +1 -0
  19. package/dist/commands/biz/item.d.ts +41 -0
  20. package/dist/commands/biz/item.d.ts.map +1 -1
  21. package/dist/commands/biz/item.js +89 -5
  22. package/dist/commands/biz/item.js.map +1 -1
  23. package/dist/commands/biz/record.d.ts +0 -8
  24. package/dist/commands/biz/record.d.ts.map +1 -1
  25. package/dist/commands/biz/record.js +29 -8
  26. package/dist/commands/biz/record.js.map +1 -1
  27. package/dist/commands/biz/task.d.ts +38 -0
  28. package/dist/commands/biz/task.d.ts.map +1 -0
  29. package/dist/commands/biz/task.js +110 -0
  30. package/dist/commands/biz/task.js.map +1 -0
  31. package/dist/commands/biz/todo.d.ts +52 -8
  32. package/dist/commands/biz/todo.d.ts.map +1 -1
  33. package/dist/commands/biz/todo.js +167 -17
  34. package/dist/commands/biz/todo.js.map +1 -1
  35. package/dist/commands/sys/health.d.ts.map +1 -1
  36. package/dist/commands/sys/health.js +20 -3
  37. package/dist/commands/sys/health.js.map +1 -1
  38. package/dist/index.d.ts +5 -0
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +281 -42
  41. package/dist/index.js.map +1 -1
  42. package/dist/services/api.d.ts +22 -8
  43. package/dist/services/api.d.ts.map +1 -1
  44. package/dist/services/api.js +108 -28
  45. package/dist/services/api.js.map +1 -1
  46. package/dist/utils/env.d.ts +16 -0
  47. package/dist/utils/env.d.ts.map +1 -0
  48. package/dist/utils/env.js +78 -0
  49. package/dist/utils/env.js.map +1 -0
  50. package/dist/utils/finance.model.d.ts +80 -0
  51. package/dist/utils/finance.model.d.ts.map +1 -0
  52. package/dist/utils/finance.model.js +150 -0
  53. package/dist/utils/finance.model.js.map +1 -0
  54. package/dist/utils/output.d.ts +42 -0
  55. package/dist/utils/output.d.ts.map +1 -0
  56. package/dist/utils/output.js +124 -0
  57. package/dist/utils/output.js.map +1 -0
  58. package/dist/utils/todo.model.d.ts +55 -0
  59. package/dist/utils/todo.model.d.ts.map +1 -0
  60. package/dist/utils/todo.model.js +135 -0
  61. package/dist/utils/todo.model.js.map +1 -0
  62. package/package.json +18 -2
  63. package/src/commands/biz/calendar.ts +61 -10
  64. package/src/commands/biz/finance.ts +112 -13
  65. package/src/commands/biz/health.ts +96 -0
  66. package/src/commands/biz/item.ts +113 -6
  67. package/src/commands/biz/record.ts +32 -8
  68. package/src/commands/biz/task.ts +115 -0
  69. package/src/commands/biz/todo.ts +193 -21
  70. package/src/commands/sys/health.ts +23 -3
  71. package/src/index.ts +311 -54
  72. package/src/services/api.ts +111 -30
  73. package/src/utils/env.ts +85 -0
  74. package/src/utils/finance.model.ts +182 -0
  75. package/src/utils/output.ts +126 -0
  76. package/src/utils/todo.model.ts +167 -0
  77. package/tests/commands/finance.test.ts +281 -0
  78. package/tests/commands/item.test.ts +215 -0
  79. package/tests/commands/todo.test.ts +292 -9
  80. package/tests/services/api.test.ts +292 -20
  81. package/tests/utils/finance.model.test.ts +319 -0
  82. package/tests/utils/todo.model.test.ts +315 -0
@@ -0,0 +1,319 @@
1
+ /**
2
+ * Finance Model Tests
3
+ * 测试收支分类、账户余额计算、金额格式化
4
+ */
5
+
6
+ import {
7
+ categorizeFinance,
8
+ getDefaultCategory,
9
+ isValidCategory,
10
+ calculateBalance,
11
+ calculateFinanceStats,
12
+ formatAmount,
13
+ parseAmount,
14
+ isValidAmount,
15
+ calculateDailyAverage,
16
+ calculateBudgetRemaining,
17
+ FinanceItem,
18
+ FinanceStats,
19
+ } from '../../src/utils/finance.model';
20
+
21
+ describe('Finance Model - Categorization', () => {
22
+ describe('categorizeFinance', () => {
23
+ it('should return type if explicitly specified as income', () => {
24
+ expect(categorizeFinance(100, 'income')).toBe('income');
25
+ });
26
+
27
+ it('should return type if explicitly specified as expense', () => {
28
+ expect(categorizeFinance(100, 'expense')).toBe('expense');
29
+ });
30
+
31
+ it('should return income for positive amount with no type', () => {
32
+ expect(categorizeFinance(100)).toBe('income');
33
+ });
34
+
35
+ it('should return expense for negative amount with no type', () => {
36
+ expect(categorizeFinance(-50)).toBe('expense');
37
+ });
38
+
39
+ it('should treat zero as income by default', () => {
40
+ expect(categorizeFinance(0)).toBe('income');
41
+ });
42
+ });
43
+
44
+ describe('getDefaultCategory', () => {
45
+ it('should return other_income for income type', () => {
46
+ expect(getDefaultCategory('income')).toBe('other_income');
47
+ });
48
+
49
+ it('should return other for expense type', () => {
50
+ expect(getDefaultCategory('expense')).toBe('other');
51
+ });
52
+ });
53
+
54
+ describe('isValidCategory', () => {
55
+ it('should validate income categories', () => {
56
+ expect(isValidCategory('income', 'salary')).toBe(true);
57
+ expect(isValidCategory('income', 'bonus')).toBe(true);
58
+ expect(isValidCategory('income', 'investment')).toBe(true);
59
+ });
60
+
61
+ it('should validate expense categories', () => {
62
+ expect(isValidCategory('expense', 'food')).toBe(true);
63
+ expect(isValidCategory('expense', 'transport')).toBe(true);
64
+ expect(isValidCategory('expense', 'shopping')).toBe(true);
65
+ });
66
+
67
+ it('should reject invalid categories', () => {
68
+ expect(isValidCategory('income', 'food')).toBe(false);
69
+ expect(isValidCategory('expense', 'salary')).toBe(false);
70
+ expect(isValidCategory('expense', 'invalid')).toBe(false);
71
+ });
72
+ });
73
+ });
74
+
75
+ describe('Finance Model - Balance Calculation', () => {
76
+ const createItem = (
77
+ amount: number,
78
+ type: 'income' | 'expense',
79
+ category: string = 'other'
80
+ ): FinanceItem => ({
81
+ id: Math.random().toString(),
82
+ amount: Math.abs(amount),
83
+ type,
84
+ category,
85
+ createdAt: new Date().toISOString(),
86
+ });
87
+
88
+ it('should calculate zero balance for empty array', () => {
89
+ expect(calculateBalance([])).toBe(0);
90
+ });
91
+
92
+ it('should calculate positive balance for income only', () => {
93
+ const items = [
94
+ createItem(100, 'income'),
95
+ createItem(200, 'income'),
96
+ ];
97
+ expect(calculateBalance(items)).toBe(300);
98
+ });
99
+
100
+ it('should calculate negative balance for expense only', () => {
101
+ const items = [
102
+ createItem(50, 'expense'),
103
+ createItem(30, 'expense'),
104
+ ];
105
+ expect(calculateBalance(items)).toBe(-80);
106
+ });
107
+
108
+ it('should calculate net balance correctly', () => {
109
+ const items = [
110
+ createItem(1000, 'income', 'salary'),
111
+ createItem(500, 'expense', 'food'),
112
+ createItem(200, 'expense', 'transport'),
113
+ ];
114
+ expect(calculateBalance(items)).toBe(300);
115
+ });
116
+
117
+ it('should handle mixed income and expense', () => {
118
+ const items = [
119
+ createItem(10000, 'income', 'salary'),
120
+ createItem(3000, 'expense', 'housing'),
121
+ createItem(1500, 'expense', 'food'),
122
+ createItem(2000, 'expense', 'shopping'),
123
+ createItem(500, 'income', 'bonus'),
124
+ ];
125
+ expect(calculateBalance(items)).toBe(4000);
126
+ });
127
+ });
128
+
129
+ describe('Finance Model - Finance Stats', () => {
130
+ const createItem = (
131
+ amount: number,
132
+ type: 'income' | 'expense',
133
+ category: string
134
+ ): FinanceItem => ({
135
+ id: Math.random().toString(),
136
+ amount: Math.abs(amount),
137
+ type,
138
+ category,
139
+ createdAt: new Date().toISOString(),
140
+ });
141
+
142
+ it('should calculate correct totals', () => {
143
+ const items = [
144
+ createItem(1000, 'income', 'salary'),
145
+ createItem(200, 'income', 'bonus'),
146
+ createItem(300, 'expense', 'food'),
147
+ createItem(100, 'expense', 'transport'),
148
+ ];
149
+
150
+ const stats = calculateFinanceStats(items);
151
+ expect(stats.totalIncome).toBe(1200);
152
+ expect(stats.totalExpense).toBe(400);
153
+ expect(stats.netBalance).toBe(800);
154
+ });
155
+
156
+ it('should aggregate by category', () => {
157
+ const items = [
158
+ createItem(1000, 'income', 'salary'),
159
+ createItem(300, 'expense', 'food'),
160
+ createItem(200, 'expense', 'food'),
161
+ createItem(150, 'expense', 'transport'),
162
+ ];
163
+
164
+ const stats = calculateFinanceStats(items);
165
+ expect(stats.byCategory['salary'].income).toBe(1000);
166
+ expect(stats.byCategory['salary'].expense).toBe(0);
167
+ expect(stats.byCategory['food'].expense).toBe(500);
168
+ expect(stats.byCategory['transport'].expense).toBe(150);
169
+ });
170
+
171
+ it('should handle empty items array', () => {
172
+ const stats = calculateFinanceStats([]);
173
+ expect(stats.totalIncome).toBe(0);
174
+ expect(stats.totalExpense).toBe(0);
175
+ expect(stats.netBalance).toBe(0);
176
+ expect(Object.keys(stats.byCategory).length).toBe(0);
177
+ });
178
+
179
+ it('should handle income-only items', () => {
180
+ const items = [
181
+ createItem(1000, 'income', 'salary'),
182
+ createItem(500, 'income', 'investment'),
183
+ ];
184
+
185
+ const stats = calculateFinanceStats(items);
186
+ expect(stats.totalIncome).toBe(1500);
187
+ expect(stats.totalExpense).toBe(0);
188
+ expect(stats.netBalance).toBe(1500);
189
+ });
190
+
191
+ it('should handle expense-only items', () => {
192
+ const items = [
193
+ createItem(300, 'expense', 'food'),
194
+ createItem(200, 'expense', 'transport'),
195
+ ];
196
+
197
+ const stats = calculateFinanceStats(items);
198
+ expect(stats.totalIncome).toBe(0);
199
+ expect(stats.totalExpense).toBe(500);
200
+ expect(stats.netBalance).toBe(-500);
201
+ });
202
+ });
203
+
204
+ describe('Finance Model - Amount Formatting', () => {
205
+ describe('formatAmount', () => {
206
+ it('should format positive amount with currency', () => {
207
+ expect(formatAmount(100)).toBe('¥100.00');
208
+ });
209
+
210
+ it('should format negative amount with currency', () => {
211
+ expect(formatAmount(-50)).toBe('¥50.00');
212
+ });
213
+
214
+ it('should format with thousand separators', () => {
215
+ expect(formatAmount(1234567.89)).toBe('¥1,234,567.89');
216
+ });
217
+
218
+ it('should format with sign when showSign is true', () => {
219
+ expect(formatAmount(100, '¥', true)).toBe('+¥100.00');
220
+ expect(formatAmount(-100, '¥', true)).toBe('-¥100.00');
221
+ });
222
+
223
+ it('should handle different currencies', () => {
224
+ expect(formatAmount(100, '$')).toBe('$100.00');
225
+ expect(formatAmount(100, '€')).toBe('€100.00');
226
+ });
227
+
228
+ it('should handle decimal places', () => {
229
+ expect(formatAmount(100.1)).toBe('¥100.10');
230
+ expect(formatAmount(100.159)).toBe('¥100.16');
231
+ });
232
+ });
233
+
234
+ describe('parseAmount', () => {
235
+ it('should parse simple amount', () => {
236
+ expect(parseAmount('100')).toBe(100);
237
+ });
238
+
239
+ it('should parse amount with currency symbol', () => {
240
+ expect(parseAmount('¥100')).toBe(100);
241
+ expect(parseAmount('$50')).toBe(50);
242
+ });
243
+
244
+ it('should parse amount with thousand separators', () => {
245
+ expect(parseAmount('1,234,567')).toBe(1234567);
246
+ });
247
+
248
+ it('should parse amount with currency and separators', () => {
249
+ expect(parseAmount('¥1,234.56')).toBe(1234.56);
250
+ });
251
+
252
+ it('should return 0 for invalid input', () => {
253
+ expect(parseAmount('invalid')).toBe(0);
254
+ expect(parseAmount('')).toBe(0);
255
+ });
256
+
257
+ it('should handle negative amounts', () => {
258
+ expect(parseAmount('-¥100')).toBe(-100);
259
+ });
260
+ });
261
+
262
+ describe('isValidAmount', () => {
263
+ it('should return true for valid positive amounts', () => {
264
+ expect(isValidAmount(100)).toBe(true);
265
+ expect(isValidAmount(0.01)).toBe(true);
266
+ });
267
+
268
+ it('should return true for valid negative amounts', () => {
269
+ expect(isValidAmount(-100)).toBe(true);
270
+ });
271
+
272
+ it('should return false for zero', () => {
273
+ expect(isValidAmount(0)).toBe(false);
274
+ });
275
+
276
+ it('should return false for NaN', () => {
277
+ expect(isValidAmount(NaN)).toBe(false);
278
+ });
279
+
280
+ it('should return false for Infinity', () => {
281
+ expect(isValidAmount(Infinity)).toBe(false);
282
+ expect(isValidAmount(-Infinity)).toBe(false);
283
+ });
284
+ });
285
+ });
286
+
287
+ describe('Finance Model - Calculations', () => {
288
+ describe('calculateDailyAverage', () => {
289
+ it('should calculate daily average correctly', () => {
290
+ expect(calculateDailyAverage(3000, 30)).toBe(100);
291
+ });
292
+
293
+ it('should return 0 for 0 days', () => {
294
+ expect(calculateDailyAverage(3000, 0)).toBe(0);
295
+ });
296
+
297
+ it('should return 0 for negative days', () => {
298
+ expect(calculateDailyAverage(3000, -5)).toBe(0);
299
+ });
300
+
301
+ it('should handle decimal result', () => {
302
+ expect(calculateDailyAverage(1000, 3)).toBeCloseTo(333.33, 1);
303
+ });
304
+ });
305
+
306
+ describe('calculateBudgetRemaining', () => {
307
+ it('should calculate remaining budget correctly', () => {
308
+ expect(calculateBudgetRemaining(5000, 3000)).toBe(2000);
309
+ });
310
+
311
+ it('should handle overspent budget', () => {
312
+ expect(calculateBudgetRemaining(3000, 5000)).toBe(-2000);
313
+ });
314
+
315
+ it('should handle exact budget match', () => {
316
+ expect(calculateBudgetRemaining(3000, 3000)).toBe(0);
317
+ });
318
+ });
319
+ });
@@ -0,0 +1,315 @@
1
+ /**
2
+ * Todo Model Tests
3
+ * 测试重复规则计算、序列化、标签解析、时长格式化
4
+ */
5
+
6
+ import {
7
+ parseRepeatRule,
8
+ calculateNextOccurrence,
9
+ parseTags,
10
+ formatDuration,
11
+ parseDuration,
12
+ serializeTodo,
13
+ deserializeTodo,
14
+ RepeatRule,
15
+ } from '../../src/utils/todo.model';
16
+
17
+ describe('Todo Model - Repeat Rule', () => {
18
+ describe('parseRepeatRule', () => {
19
+ it('should parse "none" as no repeat', () => {
20
+ const rule = parseRepeatRule('none');
21
+ expect(rule.type).toBe('none');
22
+ expect(rule.interval).toBe(1);
23
+ });
24
+
25
+ it('should parse undefined as no repeat', () => {
26
+ const rule = parseRepeatRule(undefined as any);
27
+ expect(rule.type).toBe('none');
28
+ expect(rule.interval).toBe(1);
29
+ });
30
+
31
+ it('should parse empty string as no repeat', () => {
32
+ const rule = parseRepeatRule('');
33
+ expect(rule.type).toBe('none');
34
+ expect(rule.interval).toBe(1);
35
+ });
36
+
37
+ it('should parse "daily" as daily repeat', () => {
38
+ const rule = parseRepeatRule('daily');
39
+ expect(rule.type).toBe('daily');
40
+ expect(rule.interval).toBe(1);
41
+ });
42
+
43
+ it('should parse "weekly" as weekly repeat', () => {
44
+ const rule = parseRepeatRule('weekly');
45
+ expect(rule.type).toBe('weekly');
46
+ expect(rule.interval).toBe(1);
47
+ });
48
+
49
+ it('should parse "monthly" as monthly repeat', () => {
50
+ const rule = parseRepeatRule('monthly');
51
+ expect(rule.type).toBe('monthly');
52
+ expect(rule.interval).toBe(1);
53
+ });
54
+
55
+ it('should parse "2d" as daily repeat every 2 days', () => {
56
+ const rule = parseRepeatRule('2d');
57
+ expect(rule.type).toBe('daily');
58
+ expect(rule.interval).toBe(2);
59
+ });
60
+
61
+ it('should parse "3w" as weekly repeat every 3 weeks', () => {
62
+ const rule = parseRepeatRule('3w');
63
+ expect(rule.type).toBe('weekly');
64
+ expect(rule.interval).toBe(3);
65
+ });
66
+
67
+ it('should parse "1m" as monthly repeat every 1 month', () => {
68
+ const rule = parseRepeatRule('1m');
69
+ expect(rule.type).toBe('monthly');
70
+ expect(rule.interval).toBe(1);
71
+ });
72
+
73
+ it('should parse case-insensitive repeat rules', () => {
74
+ const rule1 = parseRepeatRule('DAILY');
75
+ expect(rule1.type).toBe('daily');
76
+
77
+ const rule2 = parseRepeatRule('2D');
78
+ expect(rule2.type).toBe('daily');
79
+ expect(rule2.interval).toBe(2);
80
+ });
81
+
82
+ it('should return none for unknown patterns', () => {
83
+ const rule = parseRepeatRule('yearly');
84
+ expect(rule.type).toBe('none');
85
+ });
86
+ });
87
+
88
+ describe('calculateNextOccurrence', () => {
89
+ it('should return null for no repeat rule', () => {
90
+ const rule: RepeatRule = { type: 'none', interval: 1 };
91
+ const next = calculateNextOccurrence(new Date('2026-04-12'), rule);
92
+ expect(next).toBeNull();
93
+ });
94
+
95
+ it('should calculate daily next occurrence', () => {
96
+ const rule: RepeatRule = { type: 'daily', interval: 1 };
97
+ const current = new Date('2026-04-12');
98
+ const next = calculateNextOccurrence(current, rule);
99
+ expect(next).not.toBeNull();
100
+ expect(next!.getDate()).toBe(13);
101
+ });
102
+
103
+ it('should calculate daily next occurrence with interval', () => {
104
+ const rule: RepeatRule = { type: 'daily', interval: 3 };
105
+ const current = new Date('2026-04-12');
106
+ const next = calculateNextOccurrence(current, rule);
107
+ expect(next).not.toBeNull();
108
+ expect(next!.getDate()).toBe(15);
109
+ });
110
+
111
+ it('should calculate weekly next occurrence', () => {
112
+ const rule: RepeatRule = { type: 'weekly', interval: 1 };
113
+ const current = new Date('2026-04-12');
114
+ const next = calculateNextOccurrence(current, rule);
115
+ expect(next).not.toBeNull();
116
+ expect(next!.getDate()).toBe(19); // 12 + 7 = 19
117
+ });
118
+
119
+ it('should calculate bi-weekly occurrence', () => {
120
+ const rule: RepeatRule = { type: 'weekly', interval: 2 };
121
+ const current = new Date('2026-04-12');
122
+ const next = calculateNextOccurrence(current, rule);
123
+ expect(next).not.toBeNull();
124
+ expect(next!.getDate()).toBe(26); // 12 + 14 = 26
125
+ });
126
+
127
+ it('should calculate monthly next occurrence', () => {
128
+ const rule: RepeatRule = { type: 'monthly', interval: 1 };
129
+ const current = new Date('2026-04-12');
130
+ const next = calculateNextOccurrence(current, rule);
131
+ expect(next).not.toBeNull();
132
+ expect(next!.getMonth()).toBe(4); // May (0-indexed)
133
+ expect(next!.getDate()).toBe(12);
134
+ });
135
+
136
+ it('should calculate bimonthly occurrence', () => {
137
+ const rule: RepeatRule = { type: 'monthly', interval: 2 };
138
+ const current = new Date('2026-04-12');
139
+ const next = calculateNextOccurrence(current, rule);
140
+ expect(next).not.toBeNull();
141
+ expect(next!.getMonth()).toBe(5); // June
142
+ });
143
+
144
+ it('should return null if next occurrence exceeds end date', () => {
145
+ const rule: RepeatRule = {
146
+ type: 'daily',
147
+ interval: 1,
148
+ endDate: '2026-04-13',
149
+ };
150
+ const current = new Date('2026-04-14');
151
+ const next = calculateNextOccurrence(current, rule);
152
+ expect(next).toBeNull();
153
+ });
154
+
155
+ it('should return next occurrence if within end date', () => {
156
+ const rule: RepeatRule = {
157
+ type: 'daily',
158
+ interval: 1,
159
+ endDate: '2026-04-15',
160
+ };
161
+ const current = new Date('2026-04-12');
162
+ const next = calculateNextOccurrence(current, rule);
163
+ expect(next).not.toBeNull();
164
+ expect(next!.getDate()).toBe(13);
165
+ });
166
+ });
167
+ });
168
+
169
+ describe('Todo Model - Tags Parsing', () => {
170
+ it('should parse comma-separated tags', () => {
171
+ const tags = parseTags('work,important,urgent');
172
+ expect(tags).toEqual(['work', 'important', 'urgent']);
173
+ });
174
+
175
+ it('should trim whitespace from tags', () => {
176
+ const tags = parseTags(' work , important , urgent ');
177
+ expect(tags).toEqual(['work', 'important', 'urgent']);
178
+ });
179
+
180
+ it('should filter out empty tags', () => {
181
+ const tags = parseTags('work,,important,,');
182
+ expect(tags).toEqual(['work', 'important']);
183
+ });
184
+
185
+ it('should return empty array for undefined', () => {
186
+ const tags = parseTags(undefined);
187
+ expect(tags).toEqual([]);
188
+ });
189
+
190
+ it('should return empty array for empty string', () => {
191
+ const tags = parseTags('');
192
+ expect(tags).toEqual([]);
193
+ });
194
+
195
+ it('should return empty array for whitespace-only string', () => {
196
+ const tags = parseTags(' ');
197
+ expect(tags).toEqual([]);
198
+ });
199
+
200
+ it('should handle single tag', () => {
201
+ const tags = parseTags('work');
202
+ expect(tags).toEqual(['work']);
203
+ });
204
+
205
+ it('should handle tags with spaces', () => {
206
+ const tags = parseTags('home office, weekend project');
207
+ expect(tags).toEqual(['home office', 'weekend project']);
208
+ });
209
+ });
210
+
211
+ describe('Todo Model - Duration Formatting', () => {
212
+ describe('formatDuration', () => {
213
+ it('should format 0 seconds as 0m', () => {
214
+ expect(formatDuration(0)).toBe('0m');
215
+ });
216
+
217
+ it('should format negative seconds as 0m', () => {
218
+ expect(formatDuration(-100)).toBe('0m');
219
+ });
220
+
221
+ it('should format minutes only', () => {
222
+ expect(formatDuration(45 * 60)).toBe('45m');
223
+ });
224
+
225
+ it('should format hours only', () => {
226
+ expect(formatDuration(2 * 3600)).toBe('2h');
227
+ });
228
+
229
+ it('should format hours and minutes', () => {
230
+ expect(formatDuration(1.5 * 3600)).toBe('1h30m');
231
+ });
232
+
233
+ it('should format 90 minutes correctly', () => {
234
+ expect(formatDuration(90 * 60)).toBe('1h30m');
235
+ });
236
+
237
+ it('should handle large durations', () => {
238
+ expect(formatDuration(8 * 3600 + 15 * 60)).toBe('8h15m');
239
+ });
240
+ });
241
+
242
+ describe('parseDuration', () => {
243
+ it('should parse hours and minutes', () => {
244
+ expect(parseDuration('1h30m')).toBe(1 * 3600 + 30 * 60);
245
+ });
246
+
247
+ it('should parse hours only', () => {
248
+ expect(parseDuration('2h')).toBe(2 * 3600);
249
+ });
250
+
251
+ it('should parse minutes only', () => {
252
+ expect(parseDuration('45m')).toBe(45 * 60);
253
+ });
254
+
255
+ it('should return 0 for invalid format', () => {
256
+ expect(parseDuration('invalid')).toBe(0);
257
+ });
258
+
259
+ it('should handle 0', () => {
260
+ expect(parseDuration('0h')).toBe(0);
261
+ });
262
+ });
263
+ });
264
+
265
+ describe('Todo Model - Serialization', () => {
266
+ const sampleTodo = {
267
+ id: '1',
268
+ title: '测试待办',
269
+ completed: false,
270
+ priority: 'high' as const,
271
+ dueDate: '2026-04-15',
272
+ tags: ['work', 'important'],
273
+ repeatRule: { type: 'daily' as const, interval: 1 },
274
+ estimatedDuration: 3600,
275
+ createdAt: '2026-04-12T10:00:00',
276
+ };
277
+
278
+ it('should serialize todo to JSON', () => {
279
+ const json = serializeTodo(sampleTodo);
280
+ expect(typeof json).toBe('string');
281
+ expect(json).toContain('"id":"1"');
282
+ expect(json).toContain('"title":"测试待办"');
283
+ });
284
+
285
+ it('should deserialize JSON to todo', () => {
286
+ const json = JSON.stringify(sampleTodo);
287
+ const todo = deserializeTodo(json);
288
+ expect(todo).not.toBeNull();
289
+ expect(todo!.id).toBe('1');
290
+ expect(todo!.title).toBe('测试待办');
291
+ expect(todo!.priority).toBe('high');
292
+ expect(todo!.tags).toEqual(['work', 'important']);
293
+ });
294
+
295
+ it('should return null for invalid JSON', () => {
296
+ const todo = deserializeTodo('invalid json');
297
+ expect(todo).toBeNull();
298
+ });
299
+
300
+ it('should handle todo with optional fields', () => {
301
+ const minimalTodo = {
302
+ id: '2',
303
+ title: '简单待办',
304
+ completed: false,
305
+ priority: 'low' as const,
306
+ createdAt: '2026-04-12',
307
+ };
308
+
309
+ const json = serializeTodo(minimalTodo);
310
+ const parsed = deserializeTodo(json);
311
+ expect(parsed).not.toBeNull();
312
+ expect(parsed!.dueDate).toBeUndefined();
313
+ expect(parsed!.tags).toBeUndefined();
314
+ });
315
+ });