questions get back but not displayed correctly

This commit is contained in:
Alastair Grant 2025-02-12 17:17:13 +01:00
parent 8ebee35c75
commit 8eea6be16e
10 changed files with 391 additions and 275 deletions

View file

@ -1,81 +0,0 @@
/*
This file implements a custom sidebar view for the Obsidian RAG Test Plugin.
It displays a header with a "Prepare Tests" button that, when clicked, will trigger the indexing of test notes and prepare them to be sent to an LLM for test generation.
Below the header, a file tree of test notes with status icons and test result counts is rendered.
*/
import { App, ItemView, WorkspaceLeaf } from 'obsidian';
import type { IndexedNote } from './main';
export const VIEW_TYPE = "rag-test-view";
/**
* Custom sidebar view for displaying test notes and preparing tests.
*/
export default class TestView extends ItemView {
pluginData: IndexedNote[];
/**
* Constructs the TestView.
* @param leaf The workspace leaf to attach the view.
* @param app The Obsidian application instance.
* @param pluginData The indexed notes data.
*/
constructor(leaf: WorkspaceLeaf, app: App, pluginData: IndexedNote[]) {
super(leaf);
this.pluginData = pluginData;
}
/**
* Returns the view type.
* @returns The view type identifier.
*/
getViewType(): string {
return VIEW_TYPE;
}
/**
* Returns the display text for the view.
* @returns The display text.
*/
getDisplayText(): string {
return "Test Dashboard";
}
/**
* Called when the view is opened; triggers rendering.
*/
async onOpen(): Promise<void> {
this.render();
}
/**
* Called when the view is closed.
* @returns A promise that resolves when the view is closed.
*/
async onClose(): Promise<void> {
// No additional cleanup required.
}
/**
* Renders the sidebar with a header containing a "Prepare Tests" button,
* followed by a file tree of test notes with status icons and test result counts.
*/
render(): void {
const container = this.containerEl;
container.empty();
const headerEl = container.createEl('div', { cls: 'test-view-header' });
const prepareButton = headerEl.createEl('button', { text: 'Prepare Tests' });
prepareButton.addEventListener('click', () => {
console.log('Prepare Tests clicked');
// Here you would call a function to index files and prepare data for the LLM.
});
const listEl = container.createEl('ul');
this.pluginData.forEach((note) => {
const itemEl = listEl.createEl('li');
const statusIcon = note.testStatus.testsReady ? '🟢' : '🟡';
const resultText = note.testStatus.testsReady ? ` (${note.testStatus.passed}/${note.testStatus.total})` : '';
itemEl.createEl('span', { text: `${statusIcon} ${note.filePath}${resultText}` });
});
}
}

106
main.ts
View file

@ -1,11 +1,14 @@
/*
This file is the main plugin file for the Obsidian RAG Test Plugin.
It registers a ribbon icon that, when clicked, opens the custom sidebar view (Test Dashboard).
It also handles indexing of test notes in the "Test" folder and persisting the index.
It registers a ribbon icon that, when clicked, opens the dashboard view for selecting notes.
It also registers the full-screen Question Document view and exposes a method to open that view.
It handles indexing of notes, persisting settings, and provides a settings tab for configuration.
*/
import { App, Notice, Plugin, PluginSettingTab, Setting, TFile, WorkspaceLeaf } from 'obsidian';
import TestView, { VIEW_TYPE } from './TestView';
import { App, Notice, Plugin, PluginSettingTab, Setting, TFile, WorkspaceLeaf } from "obsidian";
import TestDashboardView, { VIEW_TYPE as DASHBOARD_VIEW_TYPE } from "./src/components/TestDashboardView";
import QuestionDocumentView, { QUESTION_VIEW_TYPE } from "./src/components/QuestionDocumentView";
import SettingsTab from "./src/components/SettingsTab";
/**
* Interface for test status.
@ -27,10 +30,12 @@ export interface IndexedNote {
interface MyPluginSettings {
mySetting: string;
apiKey: string;
}
const DEFAULT_SETTINGS: MyPluginSettings = {
mySetting: 'default'
mySetting: "default",
apiKey: ""
};
/**
@ -47,39 +52,42 @@ interface MyPluginData {
export default class MyPlugin extends Plugin {
settings: MyPluginSettings;
indexedNotes: IndexedNote[] = [];
testViewLeaf: WorkspaceLeaf | null = null;
testDashboardLeaf: WorkspaceLeaf | null = null;
/**
* Called when the plugin is loaded.
*/
async onload() {
await this.loadSettings();
this.registerView(VIEW_TYPE, (leaf: WorkspaceLeaf) => new TestView(leaf, this.app, this.indexedNotes));
this.addRibbonIcon('dice', 'Test Dashboard', (evt: MouseEvent) => {
// Register dashboard view for note selection.
this.registerView(DASHBOARD_VIEW_TYPE, (leaf: WorkspaceLeaf) => new TestDashboardView(leaf, this.app, this.indexedNotes));
// Register full-screen question document view.
this.registerView(QUESTION_VIEW_TYPE, (leaf: WorkspaceLeaf) => new QuestionDocumentView(leaf, this.app, []));
this.addRibbonIcon("dice", "Test Dashboard", (evt: MouseEvent) => {
this.openTestDashboard();
});
this.addStatusBarItem().setText('RAG Test Plugin Active');
this.addStatusBarItem().setText("RAG Test Plugin Active");
this.addCommand({
id: 'open-test-dashboard',
name: 'Open Test Dashboard',
id: "open-test-dashboard",
name: "Open Test Dashboard",
callback: () => {
this.openTestDashboard();
}
});
this.addSettingTab(new SampleSettingTab(this.app, this));
this.registerDomEvent(document, 'click', (evt: MouseEvent) => {
console.log('Document clicked', evt);
this.addSettingTab(new SettingsTab(this.app, this));
this.registerDomEvent(document, "click", (evt: MouseEvent) => {
console.log("Document clicked", evt);
});
this.registerInterval(window.setInterval(() => console.log('Interval log'), 5 * 60 * 1000));
this.registerInterval(window.setInterval(() => console.log("Interval log"), 5 * 60 * 1000));
}
/**
* Called when the plugin is unloaded.
*/
onunload() {
if (this.testViewLeaf) {
this.app.workspace.detachLeavesOfType(VIEW_TYPE);
}
this.app.workspace.detachLeavesOfType(DASHBOARD_VIEW_TYPE);
this.app.workspace.detachLeavesOfType(QUESTION_VIEW_TYPE);
}
/**
@ -108,11 +116,12 @@ export default class MyPlugin extends Plugin {
}
/**
* Indexes Markdown files in the "Test" folder, extracts test readiness data, and persists the index.
* Indexes all Markdown files in the vault, extracts test readiness data, and persists the index.
*/
async indexTestNotes() {
this.indexedNotes = [];
const markdownFiles = this.app.vault.getMarkdownFiles().filter((file: TFile) => file.path.startsWith("Test/"));
// Scan all Markdown files without filtering by folder.
const markdownFiles = this.app.vault.getMarkdownFiles();
for (const file of markdownFiles) {
const content = await this.app.vault.read(file);
const testsReady = content.includes("## Test");
@ -131,59 +140,44 @@ export default class MyPlugin extends Plugin {
console.log(`Indexed: ${file.path}`);
}
await this.saveSettings();
new Notice(`Indexed ${this.indexedNotes.length} test notes`);
new Notice(`Indexed ${this.indexedNotes.length} notes`);
}
/**
* Opens the test dashboard view in the left sidebar.
*/
openTestDashboard() {
this.app.workspace.detachLeavesOfType(VIEW_TYPE);
this.app.workspace.detachLeavesOfType(DASHBOARD_VIEW_TYPE);
const leaf = this.app.workspace.getLeftLeaf(false);
if (!leaf) {
new Notice('Could not obtain workspace leaf.');
new Notice("Could not obtain workspace leaf.");
return;
}
leaf.setViewState({
type: VIEW_TYPE,
type: DASHBOARD_VIEW_TYPE,
active: true
});
this.app.workspace.revealLeaf(leaf);
this.testViewLeaf = leaf;
}
}
/**
* Settings tab for the RAG Test Plugin.
*/
class SampleSettingTab extends PluginSettingTab {
plugin: MyPlugin;
/**
* Constructs the settings tab.
* @param app The Obsidian application instance.
* @param plugin The plugin instance.
*/
constructor(app: App, plugin: MyPlugin) {
super(app, plugin);
this.plugin = plugin;
this.testDashboardLeaf = leaf;
}
/**
* Renders the settings tab.
* Opens a full-screen question document view with the provided test questions.
* @param questions - An array of test question strings.
*/
display(): void {
const { containerEl } = this;
containerEl.empty();
new Setting(containerEl)
.setName('Setting #1')
.setDesc("It's a secret")
.addText(text => text
.setPlaceholder('Enter your secret')
.setValue(this.plugin.settings.mySetting)
.onChange(async (value: string) => {
this.plugin.settings.mySetting = value;
await this.plugin.saveSettings();
}));
public openQuestionDocument(questions: string[]): void {
this.app.workspace.detachLeavesOfType(QUESTION_VIEW_TYPE);
const leaf = this.app.workspace.getRightLeaf(false);
if (!leaf) {
new Notice("Could not obtain workspace leaf for question document.");
return;
}
leaf.setViewState({
type: QUESTION_VIEW_TYPE,
active: true
});
this.app.workspace.revealLeaf(leaf);
// Instantiate the full-screen view with the questions.
new QuestionDocumentView(leaf, this.app, questions);
}
}

View file

@ -0,0 +1,98 @@
/*
This file implements a full-page view for displaying generated test questions.
It renders an interactive document where each question is displayed with an input field for the user's answer,
and includes a "Mark Test" button to submit the answers.
*/
import { App, ItemView, Notice, WorkspaceLeaf } from "obsidian";
export const QUESTION_VIEW_TYPE = "question-document-view";
/**
* A full-page view for displaying test questions.
*/
export default class QuestionDocumentView extends ItemView {
questions: string[];
/**
* Constructs the QuestionDocumentView.
* @param leaf - The workspace leaf to attach the view.
* @param app - The Obsidian application instance.
* @param questions - An array of test questions.
*/
constructor(leaf: WorkspaceLeaf, app: App, questions: string[]) {
super(leaf);
this.questions = questions;
}
/**
* Returns the view type identifier.
* @returns The view type string.
*/
getViewType(): string {
return QUESTION_VIEW_TYPE;
}
/**
* Returns the display text for the view.
* @returns The display text.
*/
getDisplayText(): string {
return "Generated Test Questions";
}
/**
* Called when the view is opened; triggers rendering.
*/
async onOpen(): Promise<void> {
this.render();
}
/**
* Called when the view is closed.
* @returns A promise that resolves when the view is closed.
*/
async onClose(): Promise<void> {
// No additional cleanup required.
}
/**
* Renders the interactive question document.
*/
render(): void {
const container = this.containerEl;
container.empty();
const titleEl = container.createEl("h1", { text: "Generated Test Questions" });
const formEl = container.createEl("form");
this.questions.forEach((question, index) => {
const questionDiv = formEl.createEl("div", { cls: "question-item" });
const label = questionDiv.createEl("label", { text: `Q${index + 1}: ${question}` });
label.style.display = "block";
const input = questionDiv.createEl("input", { type: "text" }) as HTMLInputElement;
input.placeholder = "Type your answer here";
input.style.width = "100%";
(input as HTMLElement).dataset.questionIndex = index.toString();
});
const markButton = formEl.createEl("button", { text: "Mark Test" });
markButton.type = "button";
markButton.addEventListener("click", () => {
const answers: { [key: number]: string } = {};
const inputs = formEl.querySelectorAll("input[type='text']") as NodeListOf<HTMLInputElement>;
inputs.forEach((input) => {
const idxStr = input.dataset.questionIndex;
if (idxStr !== undefined) {
const idx = parseInt(idxStr, 10);
answers[idx] = input.value;
}
});
new Notice("Test answers submitted!");
console.log("User Answers:", answers);
});
formEl.appendChild(markButton);
container.appendChild(formEl);
}
}

View file

@ -0,0 +1,57 @@
/*
This file implements the settings tab for the Obsidian RAG Test Plugin.
It allows the user to configure plugin options, including setting the OpenAI API key.
*/
import { App, PluginSettingTab, Setting } from "obsidian";
import type { IndexedNote } from "../models/types";
import type MyPlugin from "../../main";
/**
* Settings tab for the RAG Test Plugin.
*/
export default class SettingsTab extends PluginSettingTab {
plugin: MyPlugin;
/**
* Constructs the settings tab.
* @param app - The Obsidian application instance.
* @param plugin - The main plugin instance.
*/
constructor(app: App, plugin: MyPlugin) {
super(app, plugin);
this.plugin = plugin;
}
/**
* Renders the settings tab.
*/
display(): void {
const { containerEl } = this;
containerEl.empty();
new Setting(containerEl)
.setName("Setting #1")
.setDesc("It's a secret")
.addText((text) =>
text
.setPlaceholder("Enter your secret")
.setValue(this.plugin.settings.mySetting)
.onChange(async (value: string) => {
this.plugin.settings.mySetting = value;
await this.plugin.saveSettings();
})
);
new Setting(containerEl)
.setName("OpenAI API Key")
.setDesc("Enter your OpenAI API key for test generation.")
.addText((text) =>
text
.setPlaceholder("sk-...")
.setValue(this.plugin.settings.apiKey)
.onChange(async (value: string) => {
this.plugin.settings.apiKey = value;
await this.plugin.saveSettings();
})
);
}
}

View file

@ -0,0 +1,147 @@
/*
This file implements a custom sidebar view (dashboard) for the Obsidian RAG Test Plugin.
It displays a list of all indexed notes with checkboxes for selection and a "Create Tests" button.
When "Create Tests" is clicked, an OpenAI API call is made for each selected note (using that note's full content)
to generate test questions. All generated questions are then aggregated and displayed in a full-screen question document view.
*/
import { App, ItemView, Notice, WorkspaceLeaf } from "obsidian";
import type { IndexedNote } from "../models/types";
import { generateTestQuestions } from "../services/llm";
export const VIEW_TYPE = "rag-test-view";
/**
* Custom dashboard view for selecting notes and generating tests.
*/
export default class TestDashboardView extends ItemView {
pluginData: IndexedNote[];
/**
* Constructs the dashboard view.
* @param leaf - The workspace leaf to attach the view.
* @param app - The Obsidian application instance.
* @param pluginData - The array of indexed notes.
*/
constructor(leaf: WorkspaceLeaf, app: App, pluginData: IndexedNote[]) {
super(leaf);
this.pluginData = pluginData;
}
/**
* Returns the view type identifier.
* @returns The view type string.
*/
getViewType(): string {
return VIEW_TYPE;
}
/**
* Returns the display text for the view.
* @returns The display text.
*/
getDisplayText(): string {
return "Test Dashboard";
}
/**
* Called when the view is opened; triggers rendering.
*/
async onOpen(): Promise<void> {
this.render();
}
/**
* Called when the view is closed.
* @returns A promise that resolves when the view is closed.
*/
async onClose(): Promise<void> {
// No cleanup needed.
}
/**
* Renders the dashboard: a list of notes with selection checkboxes and a "Create Tests" button.
*/
async render(): Promise<void> {
const container = this.containerEl;
container.empty();
// Header with "Create Tests" button
const headerEl = container.createEl("div", { cls: "test-view-header" });
const createTestsButton = headerEl.createEl("button", { text: "Create Tests" });
createTestsButton.disabled = true;
createTestsButton.addEventListener("click", async () => {
await this.createTests();
});
// Container for the list of notes
const listEl = container.createEl("ul");
this.pluginData.forEach((note) => {
const itemEl = listEl.createEl("li");
const checkbox = itemEl.createEl("input", { type: "checkbox" });
checkbox.dataset.filePath = note.filePath;
itemEl.createEl("span", { text: ` ${note.filePath}` });
checkbox.addEventListener("change", () => {
this.updateCreateTestsButtonState(createTestsButton, listEl);
});
});
this.updateCreateTestsButtonState(createTestsButton, listEl);
}
/**
* Updates the enabled state of the "Create Tests" button based on selection.
* @param button - The button element.
* @param listEl - The container element for the checkboxes.
*/
updateCreateTestsButtonState(button: HTMLButtonElement, listEl: HTMLElement): void {
const checkboxes = listEl.querySelectorAll('input[type="checkbox"]');
let anyChecked = false;
checkboxes.forEach((cb) => {
if ((cb as HTMLInputElement).checked) {
anyChecked = true;
}
});
button.disabled = !anyChecked;
}
/**
* For each selected note, calls the LLM API to generate test questions,
* aggregates all questions, and then opens a full-screen view to display them.
*/
async createTests(): Promise<void> {
const container = this.containerEl;
const checkboxes = container.querySelectorAll('input[type="checkbox"]');
// Retrieve API key from plugin settings stored in the main plugin instance.
const plugin = (this.app as any).plugins.plugins["obsidian-rag-test-plugin"] as any;
const apiKey = plugin?.settings?.apiKey;
if (!apiKey) {
new Notice("❌ OpenAI API Key is missing! Please set it in the plugin settings.");
return;
}
let aggregatedQuestions: string[] = [];
for (const checkbox of Array.from(checkboxes)) {
const input = checkbox as HTMLInputElement;
if (input.checked) {
const filePath = input.dataset.filePath;
const note = this.pluginData.find(n => n.filePath === filePath);
if (!note) continue;
console.log(`Generating tests for: ${filePath}`);
try {
const questions = await generateTestQuestions([note], apiKey);
console.log(`Generated tests for ${filePath}:`, questions);
aggregatedQuestions.push(`Questions for ${filePath}:`);
aggregatedQuestions.push(...questions);
} catch (error) {
console.error(`Error generating tests for ${filePath}:`, error);
}
}
}
if (aggregatedQuestions.length > 0) {
// Open a full-screen view (new page) to display the aggregated test questions.
(plugin as any).openQuestionDocument(aggregatedQuestions);
} else {
new Notice("No tests generated.");
}
}
}

View file

@ -1,13 +1,28 @@
/*
This file contains shared type definitions for the Obsidian RAG Test Plugin,
including types for LLM responses and generated tests.
including types for LLM responses, generated tests, and indexed notes.
*/
/**
* Represents the response structure from the LLM API.
*/
export interface LLMResponse {
data: any;
id: string;
object: string;
created: number;
model: string;
choices: {
index: number;
message: {
role: string;
content: string;
};
}[];
usage: {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
};
}
/**
@ -35,26 +50,3 @@ export interface IndexedNote {
content: string;
testStatus: TestStatus;
}
/**
* Shared type definitions for indexed notes and LLM responses.
*/
export interface LLMResponse {
id: string;
object: string;
created: number;
model: string;
choices: {
index: number;
message: {
role: string;
content: string;
};
}[];
usage: {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
};
}

View file

@ -3,7 +3,7 @@ This file contains functions to format indexed notes into a prompt for an LLM.
It exports a function formatNotesForLLM that takes an array of IndexedNote and returns a formatted prompt string.
*/
import type { IndexedNote } from "./types";
import type { IndexedNote } from "../models/types";
/**
* Formats an array of IndexedNote objects into a structured prompt string for the LLM.
@ -18,4 +18,4 @@ export function formatNotesForLLM(notes: IndexedNote[]): string {
});
prompt += "Ensure that the test questions are relevant to the content and test key concepts.";
return prompt;
}
}

View file

@ -5,7 +5,7 @@ extracts test-related markers (if any), and returns an array of IndexedNote obje
*/
import { App } from "obsidian";
import type { IndexedNote, TestStatus } from "./types";
import type { IndexedNote, TestStatus } from "../models/types";
/**
* Scans all Markdown files in the vault and extracts test data.
@ -38,4 +38,4 @@ export async function indexTestNotes(app: App): Promise<IndexedNote[]> {
});
}
return notes;
}
}

View file

@ -1,46 +1,39 @@
/**
* Handles communication with OpenAI to generate test questions.
*/
/*
This file handles communication with OpenAI to generate test questions.
It exports a function generateTestQuestions that sends a prompt (built using formatter.ts)
to OpenAI using the provided API key and returns an array of test questions.
*/
import { formatNotesForLLM } from "./formatter";
import type { IndexedNote, LLMResponse } from "./types";
import type { IndexedNote, LLMResponse } from "../models/types";
const API_URL = "https://api.openai.com/v1/chat/completions";
// Load API key from environment variables (DO NOT hardcode!)
import { config } from "dotenv";
config(); // Load .env variables
const API_KEY = process.env.OPENAI_API_KEY;
if (!API_KEY) {
throw new Error("Missing OpenAI API key! Add it to your .env file.");
}
console.log("✅ OpenAI API Key Loaded Successfully");
/**
* Sends formatted note data to OpenAI and retrieves test questions.
* @param {IndexedNote[]} indexedNotes - The indexed notes to generate questions from.
* @returns {Promise<string[]>} - The generated test questions.
* @param indexedNotes - The indexed notes to generate questions from.
* @param apiKey - The OpenAI API key.
* @returns A promise that resolves to an array of generated test questions.
*/
export async function generateTestQuestions(indexedNotes: IndexedNote[]): Promise<string[]> {
export async function generateTestQuestions(indexedNotes: IndexedNote[], apiKey: string): Promise<string[]> {
if (!apiKey) {
throw new Error("Missing OpenAI API key! Please set it in the plugin settings.");
}
const prompt = formatNotesForLLM(indexedNotes);
const requestBody = {
model: "gpt-4-turbo", // You can change this model
model: "gpt-3.5-turbo-0125",
messages: [
{ role: "system", content: "You are a helpful AI that generates test questions from study notes." },
{ role: "system", content: "You are a helpful AI that generates test questions from study notes. Your questions should be answered with written answers." },
{ role: "user", content: prompt }
],
temperature: 0.7, // Adjust for more or less creativity
temperature: 0.7,
max_tokens: 500
};
const response = await fetch(API_URL, {
method: "POST",
headers: {
"Authorization": `Bearer ${API_KEY}`,
"Authorization": `Bearer ${apiKey}`,
"Content-Type": "application/json"
},
body: JSON.stringify(requestBody)
@ -59,4 +52,4 @@ export async function generateTestQuestions(indexedNotes: IndexedNote[]): Promis
// Split response into separate questions (assuming they are newline-separated)
return output.split("\n").filter(q => q.trim().length > 0);
}
}

View file

@ -1,84 +0,0 @@
/**
* Test script to verify the indexing, formatting, and LLM response generation.
* It mocks an Obsidian vault, runs the indexer and formatter, calls the LLM, and prints the output.
*/
import { indexTestNotes } from "./src/indexer";
import { formatNotesForLLM } from "./src/formatter";
import { generateTestQuestions } from "./src/llm";
import type { IndexedNote } from "./src/types";
// Mock Vault class to simulate Obsidian vault behavior
class MockVault {
files: { path: string; content: string }[];
constructor(files: { path: string; content: string }[]) {
this.files = files;
}
// Simulates fetching Markdown files
getMarkdownFiles() {
return this.files.map(file => ({
path: file.path,
}));
}
// Simulates reading a file's content
async read(file: { path: string }) {
const found = this.files.find(f => f.path === file.path);
if (found) {
return found.content;
}
throw new Error(`File not found: ${file.path}`);
}
}
// Mock App object
class MockApp {
vault: MockVault;
constructor(vault: MockVault) {
this.vault = vault;
}
}
// Sample test data (simulating Obsidian Markdown files)
const mockFiles = [
{
path: "Test/1.1 - The Art of Learning.md",
content: "## Key Takeaways\n- Learning is an iterative process.\n- Experimentation is crucial.\n\n## Test\n- [ ] What is the key principle of learning?",
},
{
path: "Test/1.2 - The Pyramid Principle.md",
content: "## Summary\nThe Pyramid Principle helps structure arguments.\n\n## Test\n- [x] What does the Pyramid Principle help with?",
},
{
path: "General Notes/1.3 - Random Thoughts.md",
content: "## Notes\nSome random reflections on life.",
},
];
async function runTest() {
// Initialize mock app and vault
const mockApp = new MockApp(new MockVault(mockFiles));
// Step 1: Run indexer
const indexedNotes: IndexedNote[] = await indexTestNotes(mockApp as any);
console.log("\n📂 Indexed Notes:\n", indexedNotes);
// Step 2: Run formatter
const formattedPrompt = formatNotesForLLM(indexedNotes);
console.log("\n📝 Formatted Prompt for LLM:\n", formattedPrompt);
// Step 3: Call LLM to generate test questions
try {
console.log("\n🚀 Sending request to OpenAI...\n");
const testQuestions = await generateTestQuestions(indexedNotes);
console.log("\n✅ Generated Test Questions:\n", testQuestions);
} catch (error) {
console.error("\n❌ Error in LLM call:", error);
}
}
// Execute the test
runTest();