Files
oai-swift/oAITests/ConversationExportServiceTests.swift
T
rune 53736d4c42 Add dark mode and GFM table support to HTML/PDF export
Dark: added a prefers-color-scheme: dark CSS block, so HTML export
adapts to the browser/OS theme it's later viewed in. PDF is baked at
export time so it can't respond live — instead the offscreen WKWebView
used for PDF rendering has its appearance explicitly set to
NSApp.effectiveAppearance, matching whatever mode the app is in right
now at export time.

Tables: the renderer had no table support at all (previously
documented as an intentional scope cut), so GFM pipe tables were
falling through to the plain-paragraph path and showing up as literal
"| --- | --- |" text. Added detection (a row line immediately followed
by a valid dashes/colons separator row) plus alignment parsing from
the separator's colons.
2026-07-29 08:08:16 +02:00

136 lines
5.4 KiB
Swift

//
// ConversationExportServiceTests.swift
// oAITests
//
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright (C) 2026 Rune Olsen
import Testing
import Foundation
@testable import oAI
@Suite("ConversationExportService")
struct ConversationExportServiceTests {
// MARK: - Markdown
@Test("Markdown format matches the established User/Assistant + --- convention")
func markdownFormat() {
let messages = [
Message(role: .user, content: "hi"),
Message(role: .assistant, content: "hello there"),
]
let result = ConversationExportService.markdown(messages: messages)
#expect(result == "**User**\n\nhi\n\n---\n\n**Assistant**\n\nhello there")
}
// MARK: - HTML escaping
@Test("HTML-escapes angle brackets and ampersands instead of rendering them as markup")
func escapesHTMLSpecialCharacters() {
let messages = [Message(role: .assistant, content: "<script>alert('x')</script> & more")]
let html = ConversationExportService.html(name: "Test", messages: messages)
#expect(!html.contains("<script>alert"))
#expect(html.contains("&lt;script&gt;"))
#expect(html.contains("&amp;"))
}
@Test("HTML document includes the conversation name as title and heading")
func includesConversationName() {
let messages = [Message(role: .user, content: "hi")]
let html = ConversationExportService.html(name: "My <Chat>", messages: messages)
#expect(html.contains("<title>My &lt;Chat&gt;</title>"))
#expect(html.contains("<h1>My &lt;Chat&gt;</h1>"))
}
// MARK: - Block-level markdown rendering
@Test("Renders a header line as an <h1> tag")
func rendersHeader() {
let body = ConversationExportService.renderMarkdownBody("# Title")
#expect(body.contains("<h1>Title</h1>"))
}
@Test("Renders a fenced code block with a language class, without applying inline formatting inside it")
func rendersFencedCodeBlock() {
let body = ConversationExportService.renderMarkdownBody("```swift\nlet x = **not bold**\n```")
#expect(body.contains("<pre><code class=\"language-swift\">"))
#expect(body.contains("let x = **not bold**"))
#expect(!body.contains("<strong>not bold</strong>"))
}
@Test("Renders an unordered list as <ul><li> items")
func rendersUnorderedList() {
let body = ConversationExportService.renderMarkdownBody("- one\n- two")
#expect(body.contains("<ul>"))
#expect(body.contains("<li>one</li>"))
#expect(body.contains("<li>two</li>"))
}
@Test("Renders an ordered list as <ol><li> items")
func rendersOrderedList() {
let body = ConversationExportService.renderMarkdownBody("1. first\n2. second")
#expect(body.contains("<ol>"))
#expect(body.contains("<li>first</li>"))
#expect(body.contains("<li>second</li>"))
}
@Test("Renders a horizontal rule as <hr>")
func rendersHorizontalRule() {
let body = ConversationExportService.renderMarkdownBody("above\n\n---\n\nbelow")
#expect(body.contains("<hr>"))
}
@Test("Renders a plain paragraph wrapped in <p>")
func rendersParagraph() {
let body = ConversationExportService.renderMarkdownBody("just some text")
#expect(body.contains("<p>just some text</p>"))
}
@Test("Renders inline bold, italic, and inline code within a paragraph")
func rendersInlineFormatting() {
let body = ConversationExportService.renderMarkdownBody("**bold** and *italic* and `code`")
#expect(body.contains("<strong>bold</strong>"))
#expect(body.contains("<em>italic</em>"))
#expect(body.contains("<code>code</code>"))
}
@Test("Does not apply bold/italic formatting to asterisks inside an inline code span")
func inlineCodeProtectsItsContentFromOtherInlineFormatting() {
let body = ConversationExportService.renderMarkdownBody("`**not bold**`")
#expect(body.contains("<code>**not bold**</code>"))
#expect(!body.contains("<strong>"))
}
// MARK: - Tables
@Test("Renders a GFM pipe table as a proper HTML table, not raw pipe text")
func rendersPipeTable() {
let markdown = "| Name | Age |\n| --- | --- |\n| Alice | 30 |\n| Bob | 25 |"
let body = ConversationExportService.renderMarkdownBody(markdown)
#expect(body.contains("<table>"))
#expect(body.contains("<th>Name</th>"))
#expect(body.contains("<th>Age</th>"))
#expect(body.contains("<td>Alice</td>"))
#expect(body.contains("<td>30</td>"))
#expect(body.contains("<td>Bob</td>"))
#expect(!body.contains("|---|"))
#expect(!body.contains("| --- |"))
}
@Test("Renders table column alignment from the separator row's colons")
func rendersTableAlignment() {
let markdown = "| Left | Center | Right |\n| :--- | :---: | ---: |\n| a | b | c |"
let body = ConversationExportService.renderMarkdownBody(markdown)
#expect(body.contains("text-align:left"))
#expect(body.contains("text-align:center"))
#expect(body.contains("text-align:right"))
}
@Test("A line containing a pipe with no valid separator row after it is not treated as a table")
func doesNotTreatOrdinaryPipeTextAsTable() {
let body = ConversationExportService.renderMarkdownBody("run `cmd1 | cmd2` to pipe output")
#expect(!body.contains("<table>"))
}
}