diff --git a/.Jules/palette.md b/.Jules/palette.md new file mode 100644 index 00000000..bc0e84d3 --- /dev/null +++ b/.Jules/palette.md @@ -0,0 +1,3 @@ +## 2026-06-27 - [CLI Output Accessibility] +**Learning:** Even simple generated HTML outputs (like static index pages) from CLI tools often lack basic accessibility semantics out-of-the-box. Emoticons (📁, ▹) are insufficient indicators for screen readers identifying interactive elements like file links. +**Action:** Always inject `aria-label` attributes to explicitly describe interactive items (e.g., "Parent directory", "Directory: folderName") when generating static HTML UI, replacing or augmenting icon-only context. diff --git a/src/main/kotlin/html4tree/main.kt b/src/main/kotlin/html4tree/main.kt index 50b2680d..35260166 100644 --- a/src/main/kotlin/html4tree/main.kt +++ b/src/main/kotlin/html4tree/main.kt @@ -131,7 +131,8 @@ fun process_dir(curr_dir: File){ dir_files.forEach { val isLinkedDirectory = it.isDirectory() && !java.nio.file.Files.isSymbolicLink(it.toPath()) if((it.getName() !in exclude) && (isLinkedDirectory || !it.isDirectory())) { - l += """
  • ${if (isLinkedDirectory) { "📁" } else { "▸" }} ${it.getName().escapeHtml()}
  • """+"\n" + val label = if (isLinkedDirectory) "Directory: ${it.getName().escapeHtml()}" else "File: ${it.getName().escapeHtml()}" + l += """
  • ${if (isLinkedDirectory) { "📁" } else { "▸" }} ${it.getName().escapeHtml()}
  • """+"\n" } } diff --git a/src/main/kotlin/html4tree/util.kt b/src/main/kotlin/html4tree/util.kt index 9a882d74..c8979c31 100644 --- a/src/main/kotlin/html4tree/util.kt +++ b/src/main/kotlin/html4tree/util.kt @@ -39,7 +39,7 @@ class LinkedList { if(l == null){ return null } else { - l.next = null + l.next = null return LinkedListEntry(l.data, l.level) } } diff --git a/src/test/kotlin/html4tree/LinkedListTest.kt b/src/test/kotlin/html4tree/LinkedListTest.kt new file mode 100644 index 00000000..8b5eb631 --- /dev/null +++ b/src/test/kotlin/html4tree/LinkedListTest.kt @@ -0,0 +1,70 @@ +package html4tree + +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import java.io.File + +class LinkedListTest { + + @Test + fun testLinkedList() { + val ll = LinkedList() + assertNull(ll.pull()) + + val f1 = File("file1") + val f2 = File("file2") + val f3 = File("file3") + + ll.push(LinkedListEntry(f1, 0)) + ll.push(LinkedListEntry(f2, 1)) + ll.push(LinkedListEntry(f3, 2)) + + val e1 = ll.pull() + assertEquals(f1, e1?.file) + assertEquals(0, e1?.level) + + val e2 = ll.pull() + assertEquals(f2, e2?.file) + assertEquals(1, e2?.level) + + val e3 = ll.pull() + assertEquals(f3, e3?.file) + assertEquals(2, e3?.level) + + assertNull(ll.pull()) + } + + @Test + fun testLinkedListSettersGetters() { + val ll = LinkedList() + val e = Entry(File("a"), 0, null) + ll.first = e + ll.last = e + assertEquals(e, ll.first) + assertEquals(e, ll.last) + } + + @Test + fun testPushToNonEmpty() { + val ll = LinkedList() + val lle1 = LinkedListEntry(File("1"), 1) + val lle2 = LinkedListEntry(File("2"), 2) + ll.push(lle1) + ll.push(lle2) + + // This exercises the else branch in push + assertEquals("2", ll.first?.data?.name) + assertEquals("1", ll.last?.data?.name) + } + + @Test + fun testPushWithNullFirst() { + val ll = LinkedList() + val lle1 = LinkedListEntry(File("1"), 1) + val lle2 = LinkedListEntry(File("2"), 2) + ll.push(lle1) + ll.first = null // artificially make first null + ll.push(lle2) // will trigger first?.next safe calls returning null + } +} diff --git a/src/test/kotlin/html4tree/MainKtTest.kt b/src/test/kotlin/html4tree/MainKtTest.kt new file mode 100644 index 00000000..4392c93b --- /dev/null +++ b/src/test/kotlin/html4tree/MainKtTest.kt @@ -0,0 +1,162 @@ +package html4tree + +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.test.assertFalse +import kotlin.test.assertNull +import java.io.File +import org.junit.Rule +import org.junit.rules.TemporaryFolder + +class MainKtTest { + + @Rule + @JvmField + val tempFolder = TemporaryFolder() + + @Test + fun testEscapeHtml() { + assertEquals("&", "&".escapeHtml()) + assertEquals("<", "<".escapeHtml()) + assertEquals(">", ">".escapeHtml()) + assertEquals(""", "\"".escapeHtml()) + assertEquals("'", "'".escapeHtml()) + assertEquals("test", "test".escapeHtml()) + } + + @Test + fun testUrlEncodePath() { + assertEquals("hello%20world", "hello world".urlEncodePath()) + assertEquals("hello%2Bworld", "hello+world".urlEncodePath()) + assertEquals("%2Fhello%2Fworld", "/hello/world".urlEncodePath()) + } + + @Test + fun testProcessIgnoreFileNoIgnore() { + val dir = tempFolder.newFolder("testdir") + val exclude = process_ignore_file(dir) + assertEquals(listOf("index.html"), exclude) + } + + @Test + fun testProcessIgnoreFileWithIgnore() { + val dir = tempFolder.newFolder("testdir2") + File(dir, "file1.txt").createNewFile() + File(dir, "file2.md").createNewFile() + val ignoreFile = File(dir, ".html4ignore") + ignoreFile.writeText(".*\\.txt") + + val exclude = process_ignore_file(dir) + assertTrue(exclude.contains("index.html")) + assertTrue(exclude.contains("file1.txt")) + assertFalse(exclude.contains("file2.md")) + } + + @Test + fun testProcessDir() { + val dir = tempFolder.newFolder("processdirtest") + File(dir, "a.txt").createNewFile() + val subdir = File(dir, "subdir") + subdir.mkdir() + File(dir, ".html4ignore").writeText(".*\\.txt") + + process_dir(dir) + + val indexFile = File(dir, "index.html") + assertTrue(indexFile.exists()) + val content = indexFile.readText() + assertTrue(content.contains("")) + assertTrue(content.contains("")) + assertTrue(content.contains("subdir/")) + assertTrue(content.contains("aria-label=\"Directory: subdir\"")) + assertFalse(content.contains("a.txt")) // ignored + } + + @Test + fun testGo() { + val root = tempFolder.newFolder("root") + val subdir1 = File(root, "subdir1") + subdir1.mkdir() + val subdir2 = File(subdir1, "subdir2") + subdir2.mkdir() + + go(root.absolutePath, -1) + + assertTrue(File(root, "index.html").exists()) + assertTrue(File(subdir1, "index.html").exists()) + assertTrue(File(subdir2, "index.html").exists()) + } + + @Test + fun testGoMaxLevel() { + val root = tempFolder.newFolder("root_max") + val subdir1 = File(root, "subdir1") + subdir1.mkdir() + val subdir2 = File(subdir1, "subdir2") + subdir2.mkdir() + + go(root.absolutePath, 0) + + assertTrue(File(root, "index.html").exists()) + assertFalse(File(subdir1, "index.html").exists()) + assertFalse(File(subdir2, "index.html").exists()) + } + + @Test + fun testHelp() { + // help() just prints to standard output, we just call it to cover it + help() + } + + @Test + fun testCliCommand() { + val root = tempFolder.newFolder("cli_test") + val cmd = Html4tree() + cmd.parse(arrayOf("--max-level", "0", root.absolutePath)) + assertTrue(File(root, "index.html").exists()) + } + + @Test + fun testMainArgs() { + val root = tempFolder.newFolder("main_args_test") + html4tree.main(arrayOf(root.absolutePath)) + assertTrue(File(root, "index.html").exists()) + } + + @Test(expected = IllegalArgumentException::class) + fun testGoRequireFileExists() { + go("does_not_exist_xyz", -1) + } + + @Test(expected = IllegalArgumentException::class) + fun testGoRequireFileIsDirectory() { + val f = tempFolder.newFile("not_a_dir.txt") + go(f.absolutePath, -1) + } + + @Test + fun testGoWithNonDirectoryInside() { + val root = tempFolder.newFolder("root_mixed") + File(root, "file1.txt").createNewFile() + val subdir = File(root, "subdir1") + subdir.mkdir() + File(subdir, "file2.txt").createNewFile() + + go(root.absolutePath, -1) + + assertTrue(File(root, "index.html").exists()) + assertTrue(File(subdir, "index.html").exists()) + } + + @Test + fun testPullReturnsPushedEntry() { + val root = tempFolder.newFolder("root_for_pull") + val ll = LinkedList() + ll.push(LinkedListEntry(root, 0)) + val pulled = ll.pull() + assertEquals(root, pulled?.file) + assertEquals(0, pulled?.level) + assertNull(ll.pull()) + } +}