From 127cdba5df24c182b9fe9884367b338e89fcc932 Mon Sep 17 00:00:00 2001 From: Jeremy Meiss Date: Fri, 29 Nov 2024 14:41:41 -0600 Subject: [PATCH] update test cases --- bot.js | 132 +++++++++++------------ tests/bot.test.js | 223 ++++++++++++++++++++++---------------- tests/markov.test.js | 249 +++++++++++++++++++++---------------------- 3 files changed, 323 insertions(+), 281 deletions(-) diff --git a/bot.js b/bot.js index bff7610..a465707 100644 --- a/bot.js +++ b/bot.js @@ -66,7 +66,7 @@ function debug(message, level = 'info', data = null) { } // Configuration loader -function loadConfig() { +async function loadConfig() { try { debug('Loading configuration...', 'verbose'); @@ -131,7 +131,9 @@ function loadConfig() { .map(([name]) => name); if (missingVars.length > 0) { - throw new Error(`Missing required environment variables: ${missingVars.join(', ')}`); + const error = new Error(`Missing required environment variables: ${missingVars.join(', ')}`); + debug(error.message, 'error'); + throw error; } CONFIG = envConfig; @@ -224,29 +226,34 @@ class MarkovChain { } async addData(texts) { - if (!Array.isArray(texts)) { - throw new Error('Input must be an array of strings'); + if (!Array.isArray(texts) || texts.length === 0) { + throw new Error('No valid training data found'); } - for (const text of texts) { - if (typeof text !== 'string' || !text.trim()) continue; - - const words = text.trim().split(/\s+/); - if (words.length < this.stateSize + 1) continue; + const validTexts = texts.filter(text => typeof text === 'string' && text.trim().length > 0); + if (validTexts.length === 0) { + throw new Error('No valid training data found'); + } - const startState = words.slice(0, this.stateSize).join(' '); - this.startStates.push(startState); + for (const text of validTexts) { + const words = text.trim().split(/\s+/); + if (words.length < this.stateSize) continue; for (let i = 0; i <= words.length - this.stateSize; i++) { const state = words.slice(i, i + this.stateSize).join(' '); - const nextWord = words[i + this.stateSize] || null; + const nextWord = words[i + this.stateSize]; if (!this.chain.has(state)) { this.chain.set(state, []); } + if (nextWord) { this.chain.get(state).push(nextWord); } + + if (i === 0) { + this.startStates.push(state); + } } } @@ -255,26 +262,21 @@ class MarkovChain { } } - async generate(options = {}) { - const { - maxTries = 100, - minChars = 100, - maxChars = 280 - } = options; - + async generate({ minChars = 100, maxChars = 280, maxTries = 100 } = {}) { for (let attempt = 0; attempt < maxTries; attempt++) { try { - const result = this._generateOnce(); + const result = await this._generateOnce(); if (result.length >= minChars && result.length <= maxChars) { - return { string: result }; // Return object with string property + return { string: result }; } } catch (error) { - if (attempt === maxTries - 1) { - throw new Error('Failed to generate valid text within constraints'); + if (error.message === 'No training data available') { + throw error; } + // Continue trying if it's just a generation issue + continue; } } - throw new Error('Failed to generate valid text within constraints'); } @@ -283,30 +285,36 @@ class MarkovChain { throw new Error('No training data available'); } - const startIdx = Math.floor(Math.random() * this.startStates.length); - let currentState = this.startStates[startIdx]; - let result = currentState.split(/\s+/); - let currentLength = currentState.length; + const startState = this.startStates[Math.floor(Math.random() * this.startStates.length)]; + let currentState = startState; + let result = startState; + let usedStates = new Set([startState]); - // Generate text until we hit maxChars or can't generate more - while (currentLength < 280) { - const nextWords = this.chain.get(currentState); - if (!nextWords || nextWords.length === 0) break; - - const nextWord = nextWords[Math.floor(Math.random() * nextWords.length)]; - if (!nextWord) break; + while (true) { + const possibleNextWords = this.chain.get(currentState); + if (!possibleNextWords || possibleNextWords.length === 0) { + break; + } - // Check if adding the next word would exceed maxChars - const newLength = currentLength + 1 + nextWord.length; // +1 for space - if (newLength > 280) break; + // Shuffle possible next words to increase variation + const shuffledWords = [...possibleNextWords].sort(() => Math.random() - 0.5); + let foundNew = false; + + for (const nextWord of shuffledWords) { + const newState = result.split(/\s+/).slice(-(this.stateSize - 1)).concat(nextWord).join(' '); + if (!usedStates.has(newState)) { + result += ' ' + nextWord; + currentState = newState; + usedStates.add(newState); + foundNew = true; + break; + } + } - result.push(nextWord); - currentLength = newLength; - const words = result.slice(-this.stateSize); - currentState = words.join(' '); + if (!foundNew) break; } - return result.join(' '); + return result; } } @@ -377,6 +385,7 @@ async function fetchRecentPosts() { 'Authorization': `Bearer ${blueskyToken}` } }); + const blueskyData = await blueskyResponse.json(); if (blueskyData && blueskyData.feed && Array.isArray(blueskyData.feed)) { @@ -496,38 +505,27 @@ async function getBlueskyDid() { } // Post Generation -async function generatePost(contentArray) { - if (!contentArray || contentArray.length === 0) { - throw new Error('Content array is empty. Cannot generate Markov chain.'); +async function generatePost(content) { + if (!Array.isArray(content) || content.length === 0) { + throw new Error('Content array is empty'); } - const cleanContent = contentArray.filter(content => - typeof content === 'string' && content.trim().length > 0 - ).map(content => content.trim()); - - debug(`Processing ${cleanContent.length} content items`, 'verbose'); + const validContent = content.filter(text => typeof text === 'string' && text.trim().length > 0); + if (validContent.length === 0) { + throw new Error('Content array is empty'); + } try { const markov = new MarkovChain(CONFIG.markovStateSize); - await markov.addData(cleanContent); - - const options = { - maxTries: CONFIG.markovMaxTries, + await markov.addData(validContent); + return await markov.generate({ minChars: CONFIG.markovMinChars, - maxChars: CONFIG.markovMaxChars - }; - - const result = await markov.generate(options); - debug(`Generated post length: ${result.string.length} characters`, 'verbose'); - - if (!result || !result.string) { - throw new Error('Failed to generate valid post'); - } - - return result; + maxChars: CONFIG.markovMaxChars, + maxTries: CONFIG.markovMaxTries + }); } catch (error) { debug(`Error generating Markov chain: ${error.message}`, 'error'); - throw new Error(`Failed to generate post: ${error.message}`); + throw new Error(error.message); } } diff --git a/tests/bot.test.js b/tests/bot.test.js index 9b2c11c..8320b61 100644 --- a/tests/bot.test.js +++ b/tests/bot.test.js @@ -1,114 +1,159 @@ -import { jest, describe, test, beforeEach, afterEach, expect } from '@jest/globals'; -import { debug, generatePost, loadConfig } from '../bot.js'; -import fs from 'fs'; +import { jest } from '@jest/globals'; +import { generatePost, loadConfig } from '../bot.js'; +import dotenv from 'dotenv'; import path from 'path'; import { fileURLToPath } from 'url'; +import fs from 'fs/promises'; -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -describe('Bot Utilities', () => { - let consoleLogSpy; - let consoleErrorSpy; - let originalEnv; - let originalConsoleLog; - - beforeEach(async () => { - originalEnv = process.env; - process.env = { - ...originalEnv, - MASTODON_API_URL: 'https://mastodon.social', - MASTODON_ACCESS_TOKEN: 'test_token', - BLUESKY_API_URL: 'https://bsky.social', - BLUESKY_USERNAME: 'test.user', - BLUESKY_PASSWORD: 'test_password', - MARKOV_STATE_SIZE: '2', - MARKOV_MIN_CHARS: '30', - MARKOV_MAX_CHARS: '280', - MARKOV_MAX_TRIES: '100' - }; - originalConsoleLog = console.log; - consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); - consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); - await loadConfig(); +// Load test environment variables +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +dotenv.config({ path: path.join(__dirname, '../.env.test') }); + +describe('Bot', () => { + let envBackup; + let sampleTweets; + + beforeAll(async () => { + const tweetsPath = path.join(__dirname, '../assets/tweets.txt'); + const tweetsContent = await fs.readFile(tweetsPath, 'utf-8'); + sampleTweets = tweetsContent.split('\n').filter(line => line.trim()); + }); + + beforeEach(() => { + envBackup = { ...process.env }; }); afterEach(() => { - process.env = originalEnv; - console.log = originalConsoleLog; - consoleLogSpy.mockRestore(); - consoleErrorSpy.mockRestore(); - jest.resetModules(); + process.env = envBackup; }); - describe('debug', () => { - test('should log message with verbose level when debug mode is enabled', () => { - process.env.DEBUG_MODE = 'true'; - debug('test message', 'verbose'); - expect(consoleLogSpy).toHaveBeenCalled(); - const call = consoleLogSpy.mock.calls[0][0]; - expect(call).toMatch(/\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\] \[VERBOSE\] test message/); - }); + describe('loadConfig', () => { + test('loads configuration from environment variables', async () => { + // Set up test environment + process.env = { + DEBUG_MODE: 'true', + MASTODON_API_URL: 'https://mastodon.social', + MASTODON_ACCESS_TOKEN: 'test_token', + BLUESKY_API_URL: 'https://bsky.social', + BLUESKY_USERNAME: 'test.user', + BLUESKY_PASSWORD: 'test_password', + MARKOV_STATE_SIZE: '2', + MARKOV_MIN_CHARS: '30', + MARKOV_MAX_CHARS: '280', + MARKOV_MAX_TRIES: '100', + MASTODON_SOURCE_ACCOUNTS: 'account1,account2', + BLUESKY_SOURCE_ACCOUNTS: 'account3,account4' + }; - test('should not log verbose message when debug mode is disabled', () => { - process.env.DEBUG_MODE = 'false'; - debug('test message', 'verbose'); - expect(consoleLogSpy).not.toHaveBeenCalled(); + const config = await loadConfig(); + + expect(config).toHaveProperty('mastodon.url', 'https://mastodon.social'); + expect(config).toHaveProperty('mastodon.token', 'test_token'); + expect(config).toHaveProperty('bluesky.service', 'https://bsky.social'); + expect(config).toHaveProperty('bluesky.identifier', 'test.user'); + expect(config).toHaveProperty('bluesky.password', 'test_password'); + expect(config).toHaveProperty('markovStateSize', 2); + expect(config).toHaveProperty('markovMinChars', 30); + expect(config).toHaveProperty('markovMaxChars', 280); + expect(config).toHaveProperty('markovMaxTries', 100); + expect(config.mastodonSourceAccounts).toEqual(['account1', 'account2']); + expect(config.blueskySourceAccounts).toEqual(['account3', 'account4']); }); - test('should log info message regardless of debug mode', () => { - process.env.DEBUG_MODE = 'false'; - debug('info message', 'info'); - expect(consoleLogSpy).toHaveBeenCalled(); - const call = consoleLogSpy.mock.calls[0][0]; - expect(call).toMatch(/\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\] {2}info message/); - }); + test('throws error when required variables are missing', async () => { + // Set up test environment with missing required variable + process.env = { + DEBUG_MODE: 'true', + MASTODON_ACCESS_TOKEN: 'test_token', + BLUESKY_API_URL: 'https://bsky.social', + BLUESKY_USERNAME: 'test.user', + BLUESKY_PASSWORD: 'test_password' + }; - test('should log error message with ERROR prefix', () => { - debug('error message', 'error'); - expect(consoleLogSpy).toHaveBeenCalled(); - const call = consoleLogSpy.mock.calls[0][0]; - expect(call).toMatch(/\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\] \[ERROR\] error message/); + await expect(loadConfig()).rejects.toThrow('Missing required environment variables: MASTODON_API_URL'); }); - test('should log additional data when provided', () => { - const data = { key: 'value' }; - debug('message with data', 'info', data); - expect(consoleLogSpy).toHaveBeenCalledTimes(2); - expect(consoleLogSpy.mock.calls[1][0]).toBe(data); + test('uses default values for optional parameters', async () => { + // Set up test environment with only required variables + process.env = { + DEBUG_MODE: 'true', + MASTODON_API_URL: 'https://mastodon.social', + MASTODON_ACCESS_TOKEN: 'test_token', + BLUESKY_API_URL: 'https://bsky.social', + BLUESKY_USERNAME: 'test.user', + BLUESKY_PASSWORD: 'test_password' + }; + + const config = await loadConfig(); + expect(config.markovStateSize).toBe(2); + expect(config.markovMinChars).toBe(30); + expect(config.markovMaxChars).toBe(280); + expect(config.markovMaxTries).toBe(100); + expect(config.mastodonSourceAccounts).toEqual(['Mastodon.social']); + expect(config.blueskySourceAccounts).toEqual(['bsky.social']); }); }); describe('generatePost', () => { - test('generatePost should generate valid post from test data', async () => { - console.log('\nGenerating test post...'); - console.log('-'.repeat(50)); + beforeEach(async () => { + // Set up test environment for generatePost tests + process.env = { + DEBUG_MODE: 'true', + MASTODON_API_URL: 'https://mastodon.social', + MASTODON_ACCESS_TOKEN: 'test_token', + BLUESKY_API_URL: 'https://bsky.social', + BLUESKY_USERNAME: 'test.user', + BLUESKY_PASSWORD: 'test_password', + MARKOV_STATE_SIZE: '2', + MARKOV_MIN_CHARS: '30', + MARKOV_MAX_CHARS: '280', + MARKOV_MAX_TRIES: '100' + }; + await loadConfig(); + }); - const testTweets = [ - 'This is a test tweet with #hashtag and some interesting content', - 'Another test tweet with @mention and more words to work with', - 'A third test tweet with https://example.com and additional text for context', - 'Testing multiple elements @user #topic https://test.com with expanded vocabulary', - 'Adding more sample tweets to improve Markov chain generation quality', - 'The more varied content we have, the better the output will be', - 'Including different sentence structures helps create natural text', - 'Using hashtags #testing #quality improves the authenticity', - 'Mentioning @users and sharing https://links.com makes it realistic', - 'Final test tweet with good length and natural language patterns' - ]; - - const post = await generatePost(testTweets); - - console.log('\nGenerated post:'); + test('generates valid post from content array', async () => { + console.log('\nTesting post generation:'); console.log('-'.repeat(50)); - console.log(post.string); + + const result = await generatePost(sampleTweets); + + console.log('Generated post:', result.string); + console.log(`Length: ${result.string.length} characters`); console.log('-'.repeat(50)); - console.log(`Length: ${post.string.length} characters\n`); - expect(post).toHaveProperty('string'); - expect(typeof post.string).toBe('string'); - expect(post.string.length).toBeGreaterThanOrEqual(30); - expect(post.string.length).toBeLessThanOrEqual(280); + expect(result).toHaveProperty('string'); + expect(typeof result.string).toBe('string'); + expect(result.string.length).toBeGreaterThanOrEqual(30); + expect(result.string.length).toBeLessThanOrEqual(280); + }); + + test('handles empty content array', async () => { + await expect(generatePost([])).rejects.toThrow('Content array is empty'); + }); + + test('handles invalid content', async () => { + await expect(generatePost([null, undefined, '', ' '])).rejects.toThrow('Content array is empty'); + }); + + test('generates different posts on multiple calls', async () => { + console.log('\nTesting post variation:'); + console.log('-'.repeat(50)); + + const results = await Promise.all([ + generatePost(sampleTweets), + generatePost(sampleTweets), + generatePost(sampleTweets) + ]); + + results.forEach((result, i) => { + console.log(`Post ${i + 1}:`, result.string); + console.log(`Length: ${result.string.length} characters`); + console.log('-'.repeat(50)); + }); + + const uniqueTexts = new Set(results.map(r => r.string)); + expect(uniqueTexts.size).toBeGreaterThan(1); }); }); }); diff --git a/tests/markov.test.js b/tests/markov.test.js index 8142727..153b040 100644 --- a/tests/markov.test.js +++ b/tests/markov.test.js @@ -1,149 +1,148 @@ -import { jest, describe, test, beforeEach, afterEach, expect } from '@jest/globals'; -import { MarkovChain, loadConfig } from '../bot.js'; -import fs from 'fs'; +import { jest } from '@jest/globals'; +import { MarkovChain } from '../bot.js'; +import fs from 'fs/promises'; import path from 'path'; import { fileURLToPath } from 'url'; -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); +const __dirname = path.dirname(fileURLToPath(import.meta.url)); describe('MarkovChain', () => { - let originalEnv; + let sampleTweets; + + beforeAll(async () => { + const tweetsPath = path.join(__dirname, '../assets/tweets.txt'); + const tweetsContent = await fs.readFile(tweetsPath, 'utf-8'); + sampleTweets = tweetsContent.split('\n').filter(line => line.trim()); + }); + let markov; - beforeEach(async () => { - originalEnv = process.env; - process.env = { - ...originalEnv, - MASTODON_API_URL: 'https://mastodon.social', - MASTODON_ACCESS_TOKEN: 'test_token', - BLUESKY_API_URL: 'https://bsky.social', - BLUESKY_USERNAME: 'test.user', - BLUESKY_PASSWORD: 'test_password', - MARKOV_STATE_SIZE: '2', - MARKOV_MIN_CHARS: '30', - MARKOV_MAX_CHARS: '280', - MARKOV_MAX_TRIES: '100' - }; - await loadConfig(); + beforeEach(() => { markov = new MarkovChain(2); + markov.addData(sampleTweets); }); - afterEach(() => { - process.env = originalEnv; - jest.resetModules(); - }); + describe('constructor', () => { + test('initializes with default state size', () => { + const defaultMarkov = new MarkovChain(); + expect(defaultMarkov.stateSize).toBe(2); + }); - test('should create instance with correct state size', () => { - expect(markov.stateSize).toBe(2); - expect(markov.chain).toBeInstanceOf(Map); - expect(markov.startStates).toBeInstanceOf(Array); + test('initializes with custom state size', () => { + const customMarkov = new MarkovChain(3); + expect(customMarkov.stateSize).toBe(3); + }); }); - test('should add data and generate text', async () => { - const testData = [ - 'This is a test tweet.', - 'Another test tweet.', - 'Testing tweet generation.' - ]; + describe('addData', () => { + test('processes single text input', async () => { + const text = 'the quick brown fox jumps over the lazy dog'; + await markov.addData([text]); + expect(markov.startStates.length).toBeGreaterThan(0); + expect(markov.chain.size).toBeGreaterThan(0); + }); - await markov.addData(testData); + test('processes multiple text inputs', async () => { + const texts = [ + 'the quick brown fox jumps over the lazy dog', + 'a quick brown cat sleeps under the warm sun' + ]; + await markov.addData(texts); + expect(markov.startStates.length).toBeGreaterThan(1); + expect(markov.chain.size).toBeGreaterThan(0); + }); - const generated = await markov.generate({ - minChars: 10, - maxChars: 50, - maxTries: 100 + test('handles empty input gracefully', async () => { + await expect(markov.addData([])).rejects.toThrow('No valid training data found'); }); - expect(generated).toBeDefined(); - expect(generated).toHaveProperty('string'); - expect(typeof generated.string).toBe('string'); - expect(generated.string.length).toBeGreaterThanOrEqual(10); - expect(generated.string.length).toBeLessThanOrEqual(50); + test('handles invalid input types', async () => { + await expect(markov.addData([null, undefined, '', ' '])).rejects.toThrow('No valid training data found'); + }); }); - test('should generate text within length constraints', async () => { - const testData = [ - 'This is a test tweet with some more words to work with.', - 'Another test tweet with additional content for better generation.', - 'A third test tweet to provide more context and vocabulary.', - 'Adding more sample text to improve generation quality.', - 'The more varied content we have, the better the output will be.', - 'Including different sentence structures helps create natural text.', - 'Using more words and phrases improves the generation quality.', - 'Final test sentence with good length and natural patterns.' - ]; - - await markov.addData(testData); - - const options = { - minChars: 30, - maxChars: 100, - maxTries: 100 - }; - - const generated = await markov.generate(options); - expect(generated.string.length).toBeGreaterThanOrEqual(options.minChars); - expect(generated.string.length).toBeLessThanOrEqual(options.maxChars); - }); + describe('generate', () => { + test('generates text within length constraints', async () => { + console.log('\nTesting text generation within constraints:'); + console.log('-'.repeat(50)); - test('should throw error when no valid text can be generated', async () => { - const testData = [ - 'This is a test tweet with some more words to work with.', - 'Another test tweet with additional content for better generation.', - 'A third test tweet to provide more context and vocabulary.', - 'Adding more sample text to improve generation quality.', - 'The more varied content we have, the better the output will be.', - 'Including different sentence structures helps create natural text.', - 'Using more words and phrases improves the generation quality.', - 'Final test sentence with good length and natural patterns.' - ]; - await markov.addData(testData); - - const options = { - minChars: 1000, - maxChars: 1500, - maxTries: 10 - }; - - await expect(markov.generate(options)).rejects.toThrow( - `Failed to generate text between ${options.minChars} and ${options.maxChars} characters after ${options.maxTries} attempts` - ); - }); + const result = await markov.generate({ + minChars: 30, + maxChars: 280, + maxTries: 100 + }); + + console.log('Generated:', result.string); + console.log(`Length: ${result.string.length} characters`); + console.log('-'.repeat(50)); + + expect(result.string.length).toBeGreaterThanOrEqual(30); + expect(result.string.length).toBeLessThanOrEqual(280); + }); + + test('handles impossible length constraints', async () => { + await expect(markov.generate({ + minChars: 1000, + maxChars: 2000, + maxTries: 5 + })).rejects.toThrow('Failed to generate valid text within constraints'); + }); + + test('handles no training data', async () => { + const emptyMarkov = new MarkovChain(); + await expect(emptyMarkov.generate()).rejects.toThrow('No training data available'); + }); + + test('generates different text on multiple calls', async () => { + const markov = new MarkovChain(2); + await markov.addData(sampleTweets); + + console.log('\nTesting text variation:'); + console.log('-'.repeat(50)); + + // Generate multiple texts with more relaxed constraints + const results = []; + for (let i = 0; i < 3; i++) { + try { + const result = await markov.generate({ + minChars: 10, // More lenient minimum length + maxChars: 280, + maxTries: 100 + }); + results.push(result); + } catch (error) { + console.log(`Generation ${i + 1} failed:`, error.message); + } + } + + expect(results.length).toBeGreaterThan(0); + + results.forEach((result, i) => { + console.log(`Generation ${i + 1}:`, result.string); + console.log(`Length: ${result.string.length} characters`); + console.log('-'.repeat(50)); + }); + + const uniqueTexts = new Set(results.map(r => r.string)); + expect(uniqueTexts.size).toBeGreaterThan(1); + }); + + test('respects minimum length constraint', async () => { + const result = await markov.generate({ + minChars: 50, + maxChars: 280, + maxTries: 100 + }); + expect(result.string.length).toBeGreaterThanOrEqual(50); + }); - test('should generate valid text from test data', async () => { - const testTweets = [ - 'This is a test tweet with #hashtag and some interesting content', - 'Another test tweet with @mention and more words to work with', - 'A third test tweet with https://example.com and additional text for context', - 'Testing multiple elements @user #topic https://test.com with expanded vocabulary', - 'Adding more sample tweets to improve Markov chain generation quality', - 'The more varied content we have, the better the output will be', - 'Including different sentence structures helps create natural text', - 'Using hashtags #testing #quality improves the authenticity', - 'Mentioning @users and sharing https://links.com makes it realistic', - 'Final test tweet with good length and natural language patterns' - ]; - - console.log('\nLoaded', testTweets.length, 'tweets for testing'); - - await markov.addData(testTweets); - - console.log('\nGenerated text:'); - console.log('-'.repeat(50)); - const generated = await markov.generate({ - minChars: 30, - maxChars: 280, - maxTries: 100 + test('respects maximum length constraint', async () => { + const result = await markov.generate({ + minChars: 30, + maxChars: 100, + maxTries: 100 + }); + expect(result.string.length).toBeLessThanOrEqual(100); }); - console.log(generated.string); - console.log('-'.repeat(50)); - console.log(`Length: ${generated.string.length} characters\n`); - - expect(generated).toBeDefined(); - expect(generated).toHaveProperty('string'); - expect(typeof generated.string).toBe('string'); - expect(generated.string.length).toBeGreaterThanOrEqual(30); - expect(generated.string.length).toBeLessThanOrEqual(280); }); });