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.
136 lines
5.4 KiB
Swift
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("<script>"))
|
|
#expect(html.contains("&"))
|
|
}
|
|
|
|
@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 <Chat></title>"))
|
|
#expect(html.contains("<h1>My <Chat></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>"))
|
|
}
|
|
}
|