@git.zone/tstest 1.1.0 → 1.3.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.
@@ -8,10 +8,13 @@ import { TestDirectory } from './tstest.classes.testdirectory.js';
8
8
  import { TapCombinator } from './tstest.classes.tap.combinator.js';
9
9
  import { TapParser } from './tstest.classes.tap.parser.js';
10
10
  import { TestExecutionMode } from './index.js';
11
+ import { TsTestLogger } from './tstest.logging.js';
12
+ import type { LogOptions } from './tstest.logging.js';
11
13
 
12
14
  export class TsTest {
13
15
  public testDir: TestDirectory;
14
16
  public executionMode: TestExecutionMode;
17
+ public logger: TsTestLogger;
15
18
 
16
19
  public smartshellInstance = new plugins.smartshell.Smartshell({
17
20
  executor: 'bash',
@@ -22,62 +25,57 @@ export class TsTest {
22
25
 
23
26
  public tsbundleInstance = new plugins.tsbundle.TsBundle();
24
27
 
25
- constructor(cwdArg: string, testPathArg: string, executionModeArg: TestExecutionMode) {
28
+ constructor(cwdArg: string, testPathArg: string, executionModeArg: TestExecutionMode, logOptions: LogOptions = {}) {
26
29
  this.executionMode = executionModeArg;
27
30
  this.testDir = new TestDirectory(cwdArg, testPathArg, executionModeArg);
31
+ this.logger = new TsTestLogger(logOptions);
28
32
  }
29
33
 
30
34
  async run() {
31
35
  const fileNamesToRun: string[] = await this.testDir.getTestFilePathArray();
32
- console.log(cs(plugins.figures.hamburger.repeat(80), 'cyan'));
33
- console.log('');
34
- console.log(`${logPrefixes.TsTestPrefix} FOUND ${fileNamesToRun.length} TESTFILE(S):`);
35
- for (const fileName of fileNamesToRun) {
36
- console.log(`${logPrefixes.TsTestPrefix} ${cs(fileName, 'orange')}`);
37
- }
38
- console.log('-'.repeat(48));
39
- console.log(''); // force new line
36
+
37
+ // Log test discovery
38
+ this.logger.testDiscovery(
39
+ fileNamesToRun.length,
40
+ this.testDir.testPath,
41
+ this.executionMode
42
+ );
40
43
 
41
- const tapCombinator = new TapCombinator(); // lets create the TapCombinator
44
+ const tapCombinator = new TapCombinator(this.logger); // lets create the TapCombinator
45
+ let fileIndex = 0;
42
46
  for (const fileNameArg of fileNamesToRun) {
47
+ fileIndex++;
43
48
  switch (true) {
44
49
  case process.env.CI && fileNameArg.includes('.nonci.'):
45
- console.log('!!!!!!!!!!!');
46
- console.log(
47
- `not running testfile ${fileNameArg}, since we are CI and file name includes '.nonci.' tag`
48
- );
49
- console.log('!!!!!!!!!!!');
50
+ this.logger.tapOutput(`Skipping ${fileNameArg} - marked as non-CI`);
50
51
  break;
51
52
  case fileNameArg.endsWith('.browser.ts') || fileNameArg.endsWith('.browser.nonci.ts'):
52
- const tapParserBrowser = await this.runInChrome(fileNameArg);
53
+ const tapParserBrowser = await this.runInChrome(fileNameArg, fileIndex, fileNamesToRun.length);
53
54
  tapCombinator.addTapParser(tapParserBrowser);
54
55
  break;
55
56
  case fileNameArg.endsWith('.both.ts') || fileNameArg.endsWith('.both.nonci.ts'):
56
- console.log('>>>>>>> TEST PART 1: chrome');
57
- const tapParserBothBrowser = await this.runInChrome(fileNameArg);
57
+ this.logger.sectionStart('Part 1: Chrome');
58
+ const tapParserBothBrowser = await this.runInChrome(fileNameArg, fileIndex, fileNamesToRun.length);
58
59
  tapCombinator.addTapParser(tapParserBothBrowser);
59
- console.log(cs(`|`.repeat(16), 'cyan'));
60
- console.log(''); // force new line
61
- console.log('>>>>>>> TEST PART 2: node');
62
- const tapParserBothNode = await this.runInNode(fileNameArg);
60
+ this.logger.sectionEnd();
61
+
62
+ this.logger.sectionStart('Part 2: Node');
63
+ const tapParserBothNode = await this.runInNode(fileNameArg, fileIndex, fileNamesToRun.length);
63
64
  tapCombinator.addTapParser(tapParserBothNode);
65
+ this.logger.sectionEnd();
64
66
  break;
65
67
  default:
66
- const tapParserNode = await this.runInNode(fileNameArg);
68
+ const tapParserNode = await this.runInNode(fileNameArg, fileIndex, fileNamesToRun.length);
67
69
  tapCombinator.addTapParser(tapParserNode);
68
70
  break;
69
71
  }
70
-
71
- console.log(cs(`^`.repeat(16), 'cyan'));
72
- console.log(''); // force new line
73
72
  }
74
73
  tapCombinator.evaluate();
75
74
  }
76
75
 
77
- public async runInNode(fileNameArg: string): Promise<TapParser> {
78
- console.log(`${cs('=> ', 'blue')} Running ${cs(fileNameArg, 'orange')} in node.js runtime.`);
79
- console.log(`${cs(`= `.repeat(32), 'cyan')}`);
80
- const tapParser = new TapParser(fileNameArg + ':node');
76
+ public async runInNode(fileNameArg: string, index: number, total: number): Promise<TapParser> {
77
+ this.logger.testFileStart(fileNameArg, 'node.js', index, total);
78
+ const tapParser = new TapParser(fileNameArg + ':node', this.logger);
81
79
 
82
80
  // tsrun options
83
81
  let tsrunOptions = '';
@@ -92,9 +90,8 @@ export class TsTest {
92
90
  return tapParser;
93
91
  }
94
92
 
95
- public async runInChrome(fileNameArg: string): Promise<TapParser> {
96
- console.log(`${cs('=> ', 'blue')} Running ${cs(fileNameArg, 'orange')} in chromium runtime.`);
97
- console.log(`${cs(`= `.repeat(32), 'cyan')}`);
93
+ public async runInChrome(fileNameArg: string, index: number, total: number): Promise<TapParser> {
94
+ this.logger.testFileStart(fileNameArg, 'chromium', index, total);
98
95
 
99
96
  // lets get all our paths sorted
100
97
  const tsbundleCacheDirPath = plugins.path.join(paths.cwd, './.nogit/tstest_cache');
@@ -133,11 +130,17 @@ export class TsTest {
133
130
  await server.start();
134
131
 
135
132
  // lets handle realtime comms
136
- const tapParser = new TapParser(fileNameArg + ':chrome');
133
+ const tapParser = new TapParser(fileNameArg + ':chrome', this.logger);
137
134
  const wss = new plugins.ws.WebSocketServer({ port: 8080 });
138
135
  wss.on('connection', (ws) => {
139
136
  ws.on('message', (message) => {
140
- tapParser.handleTapLog(message.toString());
137
+ const messageStr = message.toString();
138
+ if (messageStr.startsWith('console:')) {
139
+ const [, level, ...messageParts] = messageStr.split(':');
140
+ this.logger.browserConsole(messageParts.join(':'), level);
141
+ } else {
142
+ tapParser.handleTapLog(messageStr);
143
+ }
141
144
  });
142
145
  });
143
146
 
@@ -0,0 +1,358 @@
1
+ import { coloredString as cs } from '@push.rocks/consolecolor';
2
+ import * as plugins from './tstest.plugins.js';
3
+ import * as fs from 'fs';
4
+ import * as path from 'path';
5
+
6
+ export interface LogOptions {
7
+ quiet?: boolean;
8
+ verbose?: boolean;
9
+ noColor?: boolean;
10
+ json?: boolean;
11
+ logFile?: boolean;
12
+ }
13
+
14
+ export interface TestFileResult {
15
+ file: string;
16
+ passed: number;
17
+ failed: number;
18
+ total: number;
19
+ duration: number;
20
+ tests: Array<{
21
+ name: string;
22
+ passed: boolean;
23
+ duration: number;
24
+ error?: string;
25
+ }>;
26
+ }
27
+
28
+ export interface TestSummary {
29
+ totalFiles: number;
30
+ totalTests: number;
31
+ totalPassed: number;
32
+ totalFailed: number;
33
+ totalDuration: number;
34
+ fileResults: TestFileResult[];
35
+ }
36
+
37
+ export class TsTestLogger {
38
+ private options: LogOptions;
39
+ private startTime: number;
40
+ private fileResults: TestFileResult[] = [];
41
+ private currentFileResult: TestFileResult | null = null;
42
+ private currentTestLogFile: string | null = null;
43
+
44
+ constructor(options: LogOptions = {}) {
45
+ this.options = options;
46
+ this.startTime = Date.now();
47
+ }
48
+
49
+ private format(text: string, color?: string): string {
50
+ if (this.options.noColor || !color) {
51
+ return text;
52
+ }
53
+ return cs(text, color as any);
54
+ }
55
+
56
+ private log(message: string) {
57
+ if (this.options.json) {
58
+ // For JSON mode, skip console output
59
+ // JSON output is handled by logJson method
60
+ return;
61
+ }
62
+
63
+ console.log(message);
64
+
65
+ // Log to the current test file log if we're in a test and --logfile is specified
66
+ if (this.currentTestLogFile) {
67
+ this.logToTestFile(message);
68
+ }
69
+ }
70
+
71
+ private logToFile(message: string) {
72
+ // This method is no longer used since we use logToTestFile for individual test logs
73
+ // Keeping it for potential future use with a global log file
74
+ }
75
+
76
+ private logToTestFile(message: string) {
77
+ try {
78
+ // Remove ANSI color codes for file logging
79
+ const cleanMessage = message.replace(/\u001b\[[0-9;]*m/g, '');
80
+
81
+ // Append to test log file
82
+ fs.appendFileSync(this.currentTestLogFile, cleanMessage + '\n');
83
+ } catch (error) {
84
+ // Silently fail to avoid disrupting the test run
85
+ }
86
+ }
87
+
88
+ private logJson(data: any) {
89
+ const jsonString = JSON.stringify(data);
90
+ console.log(jsonString);
91
+
92
+ // Also log to test file if --logfile is specified
93
+ if (this.currentTestLogFile) {
94
+ this.logToTestFile(jsonString);
95
+ }
96
+ }
97
+
98
+ // Section separators
99
+ sectionStart(title: string) {
100
+ if (this.options.quiet || this.options.json) return;
101
+ this.log(this.format(`\n━━━ ${title} ━━━`, 'cyan'));
102
+ }
103
+
104
+ sectionEnd() {
105
+ if (this.options.quiet || this.options.json) return;
106
+ this.log(this.format('─'.repeat(50), 'dim'));
107
+ }
108
+
109
+ // Progress indication
110
+ progress(current: number, total: number, message: string) {
111
+ if (this.options.quiet || this.options.json) return;
112
+ const percentage = Math.round((current / total) * 100);
113
+ const filled = Math.round((current / total) * 20);
114
+ const empty = 20 - filled;
115
+
116
+ this.log(this.format(`\nšŸ“Š Progress: ${current}/${total} (${percentage}%)`, 'cyan'));
117
+ this.log(this.format(`[${'ā–ˆ'.repeat(filled)}${'ā–‘'.repeat(empty)}] ${message}`, 'dim'));
118
+ }
119
+
120
+ // Test discovery
121
+ testDiscovery(count: number, pattern: string, executionMode: string) {
122
+ if (this.options.json) {
123
+ this.logJson({ event: 'discovery', count, pattern, executionMode });
124
+ return;
125
+ }
126
+
127
+ if (this.options.quiet) {
128
+ this.log(`Found ${count} tests`);
129
+ } else {
130
+ this.log(this.format(`\nšŸ” Test Discovery`, 'bold'));
131
+ this.log(this.format(` Mode: ${executionMode}`, 'dim'));
132
+ this.log(this.format(` Pattern: ${pattern}`, 'dim'));
133
+ this.log(this.format(` Found: ${count} test file(s)`, 'green'));
134
+ }
135
+ }
136
+
137
+ // Test execution
138
+ testFileStart(filename: string, runtime: string, index: number, total: number) {
139
+ this.currentFileResult = {
140
+ file: filename,
141
+ passed: 0,
142
+ failed: 0,
143
+ total: 0,
144
+ duration: 0,
145
+ tests: []
146
+ };
147
+
148
+ // Only set up test log file if --logfile option is specified
149
+ if (this.options.logFile) {
150
+ const baseFilename = path.basename(filename, '.ts');
151
+ this.currentTestLogFile = path.join('.nogit', 'testlogs', `${baseFilename}.log`);
152
+
153
+ // Ensure the directory exists
154
+ const logDir = path.dirname(this.currentTestLogFile);
155
+ if (!fs.existsSync(logDir)) {
156
+ fs.mkdirSync(logDir, { recursive: true });
157
+ }
158
+
159
+ // Clear the log file for this test
160
+ fs.writeFileSync(this.currentTestLogFile, '');
161
+ }
162
+
163
+ if (this.options.json) {
164
+ this.logJson({ event: 'fileStart', filename, runtime, index, total });
165
+ return;
166
+ }
167
+
168
+ if (this.options.quiet) return;
169
+
170
+ this.log(this.format(`\nā–¶ļø ${filename} (${index}/${total})`, 'blue'));
171
+ this.log(this.format(` Runtime: ${runtime}`, 'dim'));
172
+ }
173
+
174
+ testResult(testName: string, passed: boolean, duration: number, error?: string) {
175
+ if (this.currentFileResult) {
176
+ this.currentFileResult.tests.push({ name: testName, passed, duration, error });
177
+ this.currentFileResult.total++;
178
+ if (passed) {
179
+ this.currentFileResult.passed++;
180
+ } else {
181
+ this.currentFileResult.failed++;
182
+ }
183
+ this.currentFileResult.duration += duration;
184
+ }
185
+
186
+ if (this.options.json) {
187
+ this.logJson({ event: 'testResult', testName, passed, duration, error });
188
+ return;
189
+ }
190
+
191
+ const icon = passed ? 'āœ…' : 'āŒ';
192
+ const color = passed ? 'green' : 'red';
193
+
194
+ if (this.options.quiet) {
195
+ this.log(`${icon} ${testName}`);
196
+ } else {
197
+ this.log(this.format(` ${icon} ${testName} (${duration}ms)`, color));
198
+ if (error && !passed) {
199
+ this.log(this.format(` ${error}`, 'red'));
200
+ }
201
+ }
202
+ }
203
+
204
+ testFileEnd(passed: number, failed: number, duration: number) {
205
+ if (this.currentFileResult) {
206
+ this.fileResults.push(this.currentFileResult);
207
+ this.currentFileResult = null;
208
+ }
209
+
210
+ if (this.options.json) {
211
+ this.logJson({ event: 'fileEnd', passed, failed, duration });
212
+ return;
213
+ }
214
+
215
+ if (!this.options.quiet) {
216
+ const total = passed + failed;
217
+ const status = failed === 0 ? 'PASSED' : 'FAILED';
218
+ const color = failed === 0 ? 'green' : 'red';
219
+ this.log(this.format(` Summary: ${passed}/${total} ${status}`, color));
220
+ }
221
+
222
+ // Clear the current test log file reference only if using --logfile
223
+ if (this.options.logFile) {
224
+ this.currentTestLogFile = null;
225
+ }
226
+ }
227
+
228
+ // TAP output forwarding (for TAP protocol messages)
229
+ tapOutput(message: string, isError: boolean = false) {
230
+ if (this.options.json) return;
231
+
232
+ // Never show raw TAP protocol messages in console
233
+ // They are already processed by TapParser and shown in our format
234
+
235
+ // Always log to test file if --logfile is specified
236
+ if (this.currentTestLogFile) {
237
+ this.logToTestFile(` ${message}`);
238
+ }
239
+ }
240
+
241
+ // Console output from test files (non-TAP output)
242
+ testConsoleOutput(message: string) {
243
+ if (this.options.json) return;
244
+
245
+ // Show console output from test files only in verbose mode
246
+ if (this.options.verbose) {
247
+ this.log(this.format(` ${message}`, 'dim'));
248
+ }
249
+
250
+ // Always log to test file if --logfile is specified
251
+ if (this.currentTestLogFile) {
252
+ this.logToTestFile(` ${message}`);
253
+ }
254
+ }
255
+
256
+ // Browser console
257
+ browserConsole(message: string, level: string = 'log') {
258
+ if (this.options.json) {
259
+ this.logJson({ event: 'browserConsole', message, level });
260
+ return;
261
+ }
262
+
263
+ if (!this.options.quiet) {
264
+ const prefix = level === 'error' ? 'šŸŒāŒ' : '🌐';
265
+ const color = level === 'error' ? 'red' : 'magenta';
266
+ this.log(this.format(` ${prefix} ${message}`, color));
267
+ }
268
+ }
269
+
270
+ // Final summary
271
+ summary() {
272
+ const totalDuration = Date.now() - this.startTime;
273
+ const summary: TestSummary = {
274
+ totalFiles: this.fileResults.length,
275
+ totalTests: this.fileResults.reduce((sum, r) => sum + r.total, 0),
276
+ totalPassed: this.fileResults.reduce((sum, r) => sum + r.passed, 0),
277
+ totalFailed: this.fileResults.reduce((sum, r) => sum + r.failed, 0),
278
+ totalDuration,
279
+ fileResults: this.fileResults
280
+ };
281
+
282
+ if (this.options.json) {
283
+ this.logJson({ event: 'summary', summary });
284
+ return;
285
+ }
286
+
287
+ if (this.options.quiet) {
288
+ const status = summary.totalFailed === 0 ? 'PASSED' : 'FAILED';
289
+ this.log(`\nSummary: ${summary.totalPassed}/${summary.totalTests} | ${totalDuration}ms | ${status}`);
290
+ return;
291
+ }
292
+
293
+ // Detailed summary
294
+ this.log(this.format('\nšŸ“Š Test Summary', 'bold'));
295
+ this.log(this.format('ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”', 'dim'));
296
+ this.log(this.format(`│ Total Files: ${summary.totalFiles.toString().padStart(14)} │`, 'white'));
297
+ this.log(this.format(`│ Total Tests: ${summary.totalTests.toString().padStart(14)} │`, 'white'));
298
+ this.log(this.format(`│ Passed: ${summary.totalPassed.toString().padStart(14)} │`, 'green'));
299
+ this.log(this.format(`│ Failed: ${summary.totalFailed.toString().padStart(14)} │`, summary.totalFailed > 0 ? 'red' : 'green'));
300
+ this.log(this.format(`│ Duration: ${totalDuration.toString().padStart(14)}ms │`, 'white'));
301
+ this.log(this.format('ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜', 'dim'));
302
+
303
+ // File results
304
+ if (summary.totalFailed > 0) {
305
+ this.log(this.format('\nāŒ Failed Tests:', 'red'));
306
+ this.fileResults.forEach(fileResult => {
307
+ if (fileResult.failed > 0) {
308
+ this.log(this.format(`\n ${fileResult.file}`, 'yellow'));
309
+ fileResult.tests.filter(t => !t.passed).forEach(test => {
310
+ this.log(this.format(` āŒ ${test.name}`, 'red'));
311
+ if (test.error) {
312
+ this.log(this.format(` ${test.error}`, 'dim'));
313
+ }
314
+ });
315
+ }
316
+ });
317
+ }
318
+
319
+ // Performance metrics
320
+ if (this.options.verbose) {
321
+ const avgDuration = Math.round(totalDuration / summary.totalTests);
322
+ const slowestTest = this.fileResults
323
+ .flatMap(r => r.tests)
324
+ .sort((a, b) => b.duration - a.duration)[0];
325
+
326
+ this.log(this.format('\nā±ļø Performance Metrics:', 'cyan'));
327
+ this.log(this.format(` Average per test: ${avgDuration}ms`, 'white'));
328
+ if (slowestTest) {
329
+ this.log(this.format(` Slowest test: ${slowestTest.name} (${slowestTest.duration}ms)`, 'yellow'));
330
+ }
331
+ }
332
+
333
+ // Final status
334
+ const status = summary.totalFailed === 0 ? 'ALL TESTS PASSED! šŸŽ‰' : 'SOME TESTS FAILED! āŒ';
335
+ const statusColor = summary.totalFailed === 0 ? 'green' : 'red';
336
+ this.log(this.format(`\n${status}`, statusColor));
337
+ }
338
+
339
+ // Error display
340
+ error(message: string, file?: string, stack?: string) {
341
+ if (this.options.json) {
342
+ this.logJson({ event: 'error', message, file, stack });
343
+ return;
344
+ }
345
+
346
+ if (this.options.quiet) {
347
+ console.error(`ERROR: ${message}`);
348
+ } else {
349
+ this.log(this.format('\nāš ļø Error', 'red'));
350
+ if (file) this.log(this.format(` File: ${file}`, 'yellow'));
351
+ this.log(this.format(` ${message}`, 'red'));
352
+ if (stack && this.options.verbose) {
353
+ this.log(this.format(` Stack:`, 'dim'));
354
+ this.log(this.format(stack.split('\n').map(line => ` ${line}`).join('\n'), 'dim'));
355
+ }
356
+ }
357
+ }
358
+ }
package/readme.plan.md DELETED
@@ -1,51 +0,0 @@
1
- # Plan for adding single file and glob pattern execution support to tstest
2
-
3
- !! FIRST: Reread /home/philkunz/.claude/CLAUDE.md to ensure following all guidelines !!
4
-
5
- ## Goal - āœ… COMPLETED
6
- - āœ… Make `tstest test/test.abc.ts` run the specified file directly
7
- - āœ… Support glob patterns like `tstest test/*.spec.ts` or `tstest test/**/*.test.ts`
8
- - āœ… Maintain backward compatibility with directory argument
9
-
10
- ## Current behavior - UPDATED
11
- - āœ… tstest now supports three modes: directory, single file, and glob patterns
12
- - āœ… Directory mode now searches recursively using `**/test*.ts` pattern
13
- - āœ… Single file mode runs a specific test file
14
- - āœ… Glob mode runs files matching the pattern
15
-
16
- ## Completed changes
17
-
18
- ### 1. āœ… Update cli argument handling in index.ts
19
- - āœ… Detect argument type: file path, glob pattern, or directory
20
- - āœ… Check if argument contains glob characters (*, **, ?, [], etc.)
21
- - āœ… Pass appropriate mode to TsTest constructor
22
- - āœ… Added TestExecutionMode enum
23
-
24
- ### 2. āœ… Modify TsTest constructor and class
25
- - āœ… Add support for three modes: directory, file, glob
26
- - āœ… Update constructor to accept pattern/path and mode
27
- - āœ… Added executionMode property to track the mode
28
-
29
- ### 3. āœ… Update TestDirectory class
30
- - āœ… Used `listFileTree` for glob pattern support
31
- - āœ… Used `SmartFile.fromFilePath` for single file loading
32
- - āœ… Refactored to support all three modes in `_init` method
33
- - āœ… Return appropriate file array based on mode
34
- - āœ… Changed default directory behavior to recursive search
35
- - āœ… When directory argument: use `**/test*.ts` pattern for recursive search
36
- - āœ… This ensures subdirectories are included in test discovery
37
-
38
- ### 4. āœ… Test the implementation
39
- - āœ… Created test file `test/test.single.ts` for single file functionality
40
- - āœ… Created test file `test/test.glob.ts` for glob pattern functionality
41
- - āœ… Created test in subdirectory `test/subdir/test.sub.ts` for recursive search
42
- - āœ… Tested with existing test files for backward compatibility
43
- - āœ… Tested glob patterns: `test/test.*.ts` works correctly
44
- - āœ… Verified that default behavior now includes subdirectories
45
-
46
- ## Implementation completed
47
- 1. āœ… CLI argument type detection implemented
48
- 2. āœ… TsTest class supports all three modes
49
- 3. āœ… TestDirectory handles files, globs, and directories
50
- 4. āœ… Default pattern changed from `test*.ts` to `**/test*.ts` for recursive search
51
- 5. āœ… Comprehensive tests added and all modes verified