import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import matter from 'gray-matter';
import {MODEL,extractSource,collectSources,discoverSources,validateDraft,writeDraft,saveArticle} from '../scripts/generate.mjs';
import {readArticles,articlePage} from '../scripts/site.mjs';
const source={id:1,title:'Original reporting',url:'https://example.org/report',text:'An original source. '.repeat(40),published:'2026-09-10',retrievedAt:'2026-09-12T12:00:00Z'};
const draft={title:'An accurate summary',description:'A short introduction.',category:'Technology',paragraphs:Array.from({length:3},()=>({text:'A factual paragraph grounded in the supplied original source.',sources:[1]}))};
test('extracts article content and removes navigation and scripts',()=>{
const s=extractSource(`
Report
Reporting
${source.text}
`,source.url);
assert.match(s.text,/Reporting/);assert.doesNotMatch(s.text,/Wrong navigation|ignore all rules/);
assert.throws(()=>extractSource('Just a moment',source.url),/No readable/);
});
test('local search sends the prompt and filters invalid or duplicate URLs',async()=>{
const fetchImpl=async input=>{
const url=new URL(input);
assert.equal(url.origin,'http://localhost:8888');
assert.equal(url.pathname,'/search');assert.equal(url.searchParams.get('q'),'climate & energy');
assert.equal(url.searchParams.get('format'),'json');
return Response.json({results:[{url:source.url},{url:source.url+'#part'},{url:'javascript:bad'},{title:'Missing URL'}]});
};
assert.deepEqual(await discoverSources('climate & energy',{fetchImpl,apiKey:'',searxngUrl:'http://localhost:8888/search'}),[source.url]);
});
test('search errors explain service startup and empty results',async()=>{
await assert.rejects(discoverSources('news',{apiKey:'',searxngUrl:'http://localhost:8888',fetchImpl:async()=>{throw new Error('Connection refused');}}),/npm run search:start/);
await assert.rejects(discoverSources('news',{apiKey:'',searxngUrl:'http://localhost:8888',fetchImpl:async()=>Response.json({results:[],unresponsive_engines:[['google','timeout']]})}),/Unavailable engines: google/);
});
test('Brave remains available when a key is supplied without a SearXNG override',async()=>{
const fetchImpl=async(url,options)=>{assert.match(url,/api.search.brave.com/);assert.equal(options.headers['X-Subscription-Token'],'test-key');return Response.json({web:{results:[{url:source.url}]}});};
assert.deepEqual(await discoverSources('news',{fetchImpl,apiKey:'test-key',searxngUrl:''}),[source.url]);
});
test('refuses generation without readable sources',async()=>{
await assert.rejects(collectSources('news',[source.url],{fetchImpl:async()=>new Response('blocked',{status:403}),log:()=>{}}),/No readable sources/);
});
test('rejects invented citation IDs, missing citations and URLs',()=>{
for(const paragraph of [{text:draft.paragraphs[0].text,sources:[2]},{text:draft.paragraphs[0].text,sources:[]},{text:'See https://invented.example for the details.',sources:[1]}]) assert.throws(()=>validateDraft({...draft,paragraphs:[paragraph,...draft.paragraphs.slice(1)]},[source]));
});
test('uses only the smaller model and validates the Ollama response',async()=>{
let body;
const fetchImpl=async(url,options)=>{assert.equal(url,'http://localhost:11434/api/chat');body=JSON.parse(options.body);return Response.json({message:{content:JSON.stringify(draft)}});};
assert.deepEqual(await writeDraft('news',[source],{fetchImpl,endpoint:'http://localhost:11434/'}),draft);
assert.equal(body.model,'llama3.2:3b');assert.equal(body.model,MODEL);assert.equal(body.stream,false);assert.ok(body.format.properties.paragraphs);
await assert.rejects(writeDraft('news',[source],{log:()=>{},fetchImpl:async()=>Response.json({message:{content:'invalid'}})}),/valid article JSON/);
});
test('saved Markdown has source metadata, citations and a working custom route',async t=>{
const directory=await fs.mkdtemp(path.join(os.tmpdir(),'truenews-generation-'));t.after(()=>fs.rm(directory,{recursive:true,force:true}));
const result=await saveArticle(draft,[source],'A short prompt',{directory,url:'/technology/test-article/'});
const {data,content}=matter(await fs.readFile(result.filename,'utf8'));
assert.equal(data.model,MODEL);assert.equal(data.sources[0].url,source.url);assert.match(content,/## Sources/);
const articles=await readArticles(directory);assert.equal(articles[0].url,result.url);
assert.match(articlePage(articles[0],articles),/href="https:\/\/example.org\/report"/);
assert.match(articles[0].html,/AI-generated/);
await assert.rejects(saveArticle(draft,[source],'again',{directory,url:result.url}),/already uses/);
assert.equal((await fs.readdir(directory)).length,1);
});
test('schema restricts citation numbers to actual, possibly nonconsecutive source IDs',async()=>{
const sources=[{...source,id:2},{...source,id:7}];
const valid={...draft,paragraphs:draft.paragraphs.map(p=>({...p,sources:[7]}))};
const fetchImpl=async(_,options)=>{
const body=JSON.parse(options.body);
const citations=body.format.properties.paragraphs.items.properties.sources;
assert.deepEqual(citations.items.enum,[2,7]);assert.equal(citations.minItems,1);
assert.match(body.messages[0].content,/Copy the id field/);
return Response.json({message:{content:JSON.stringify(valid)}});
};
assert.deepEqual(await writeDraft('news',sources,{fetchImpl}),valid);
});
test('invalid source citation triggers one correction before an article is accepted',async()=>{
let calls=0;
const messages=[];
const invalid={...draft,paragraphs:draft.paragraphs.map(p=>({...p,sources:[0]}))};
const fetchImpl=async(_,options)=>{
calls++;
const body=JSON.parse(options.body);
if(calls===2){
assert.equal(body.messages.length,4);
assert.match(body.messages.at(-1).content,/Paragraph 1 returned an unknown or missing source citation/);
assert.match(body.messages.at(-1).content,/Do not guess a replacement citation/);
}
return Response.json({message:{content:JSON.stringify(calls===1?invalid:draft)}});
};
assert.deepEqual(await writeDraft('news',[source],{fetchImpl,log:m=>messages.push(m)}),draft);
assert.equal(calls,2);assert.equal(messages.length,1);
assert.deepEqual(invalid.paragraphs[0].sources,[0]);
});
test('repeated missing citations stop after two attempts without accepting a draft',async()=>{
let calls=0;
const invalid={...draft,paragraphs:draft.paragraphs.map(p=>({...p,sources:[]}))};
await assert.rejects(writeDraft('news',[source],{log:()=>{},fetchImpl:async()=>{calls++;return Response.json({message:{content:JSON.stringify(invalid)}});}}),/after 2 attempts.*No article was saved/);
assert.equal(calls,2);
});
test('truncated output is retried, but HTTP errors are not treated as validation errors',async()=>{
let calls=0;
const fetchImpl=async()=>{calls++;return Response.json(calls===1?{done_reason:'length',message:{content:'{"title":'}}:{message:{content:JSON.stringify(draft)}});};
assert.deepEqual(await writeDraft('news',[source],{fetchImpl,log:()=>{}}),draft);assert.equal(calls,2);
calls=0;
await assert.rejects(writeDraft('news',[source],{fetchImpl:async()=>{calls++;return new Response('Model not found',{status:404});}}),/HTTP 404/);
assert.equal(calls,1);
});
test('token-limit recovery uses shorter bounds, a larger budget and no partial JSON history',async()=>{
const partial='{"title":"Unfinished output';
const requests=[];
const fetchImpl=async(_,options)=>{
requests.push(JSON.parse(options.body));
return Response.json(requests.length===1
? {done_reason:'length',message:{content:partial}}
: {message:{content:JSON.stringify(draft)}});
};
assert.deepEqual(await writeDraft('news',[source],{fetchImpl,log:()=>{}}),draft);
const [first,retry]=requests;
assert.equal(first.options.num_predict,2048);
assert.equal(retry.options.num_predict,4096);
assert.equal(retry.model,MODEL);
assert.equal(retry.format.properties.paragraphs.minItems,3);
assert.equal(retry.format.properties.paragraphs.maxItems,3);
assert.equal(retry.format.properties.paragraphs.items.properties.text.maxLength,450);
assert.deepEqual(retry.format.properties.paragraphs.items.properties.sources.items.enum,[1]);
assert.equal(retry.messages.length,3);
assert.ok(retry.messages.every(m=>m.role!=='assistant'));
assert.ok(!JSON.stringify(retry.messages).includes(partial));
assert.match(retry.messages[0].content,/exactly 3 short paragraphs, about 100–160/);
assert.ok(retry.messages[0].content.includes(JSON.stringify(retry.format)));
});
test('complete validated JSON at the token limit is accepted without another request',async()=>{
let calls=0;
const result=await writeDraft('news',[source],{fetchImpl:async()=>{calls++;return Response.json({done_reason:'length',message:{content:JSON.stringify(draft)}});}});
assert.deepEqual(result,draft);assert.equal(calls,1);
});