From ad3708733f4e9f2b326f5b0f254c08a51a48d6b7 Mon Sep 17 00:00:00 2001 From: Xavier Roche Date: Wed, 12 Aug 2026 22:44:18 +0200 Subject: [PATCH 1/3] Host aliases sits three tabs away from the switch that gates it Move the field from Experts only to Spider, under URL hacks. The engine folds a www. alias only when that checkbox is on (hts_host_alias_collapse_www is urlhack && !no_www_dedup), and both other front ends already keep the field beside it: WinHTTrack on IDD_OPTION8, WebHTTrack on option8.html. guide.html documents it in the opt-spider section, so Help from the tab now opens a page that mentions the field. A tab looks its @Fields ids up in its own inflated layout and WidgetDataExchange throws on a view it cannot find, so a test checks every tab's declared ids against that tab's layout. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Xavier Roche --- .../com/httrack/android/OptionsActivity.java | 6 +- .../layout/activity_options_expertsonly.xml | 13 --- .../res/layout/activity_options_spider.xml | 13 +++ .../httrack/android/OptionsTabFieldsTest.java | 92 +++++++++++++++++++ 4 files changed, 108 insertions(+), 16 deletions(-) create mode 100644 app/src/test/java/com/httrack/android/OptionsTabFieldsTest.java diff --git a/app/src/main/java/com/httrack/android/OptionsActivity.java b/app/src/main/java/com/httrack/android/OptionsActivity.java index 5e42ae8e..2d90aeec 100755 --- a/app/src/main/java/com/httrack/android/OptionsActivity.java +++ b/app/src/main/java/com/httrack/android/OptionsActivity.java @@ -207,7 +207,8 @@ public static class BrowserId extends Tab { @Fields({ R.id.checkAcceptCookies, R.id.editCookiesFile, R.id.radioCheckDocumentType, R.id.checkParseJavaFiles, R.id.radioSpider, R.id.checkSitemap, R.id.editSitemapUrl, R.id.checkUpdateHacks, - R.id.checkUrlHacks, R.id.checkTolerentRequests, R.id.checkForceHttp10 }) + R.id.checkUrlHacks, R.id.editHostAlias, R.id.checkTolerentRequests, + R.id.checkForceHttp10 }) public static class Spider extends Tab { } @@ -246,8 +247,7 @@ public static class MimeDefs extends Tab { @HelpPage("guide.html#droid/opt-experts-only") @Fields({ R.id.checkUseCacheForUpdates, R.id.radioPrimaryScanRule, R.id.textTravelMode, R.id.radioTravelMode, R.id.radioGlobalTravelMode, - R.id.radioRewriteLinks, R.id.editStripQuery, R.id.editHostAlias, - R.id.checkActivateDebugging }) + R.id.radioRewriteLinks, R.id.editStripQuery, R.id.checkActivateDebugging }) public static class ExpertsOnly extends Tab { } diff --git a/app/src/main/res/layout/activity_options_expertsonly.xml b/app/src/main/res/layout/activity_options_expertsonly.xml index 990322c2..3b684967 100644 --- a/app/src/main/res/layout/activity_options_expertsonly.xml +++ b/app/src/main/res/layout/activity_options_expertsonly.xml @@ -205,19 +205,6 @@ android:inputType="textNoSuggestions" /> - - - - + + + + layouts(final String layout) { + final List files = new ArrayList(); + for (final File res : dir("src/main/res").listFiles()) { + if (!res.isDirectory() || !res.getName().startsWith("layout")) { + continue; + } + final File file = new File(res, layout + ".xml"); + if (file.isFile()) { + files.add(file); + } + } + return files; + } + + private static Set declaredIds(final File layout) throws IOException { + final Set ids = new HashSet(); + final Matcher m = Pattern.compile("android:id=\"@\\+?id/(\\w+)\"").matcher( + new String(Files.readAllBytes(layout.toPath()), "UTF-8")); + while (m.find()) { + ids.add(m.group(1)); + } + return ids; + } + + @Test + public void everyTabFieldLivesInThatTabsLayout() throws IOException { + final String src = new String(Files.readAllBytes(Paths + .get(dir("src/main/java").getPath(), + "com/httrack/android/OptionsActivity.java")), "UTF-8"); + final Matcher tab = TAB.matcher(src); + int tabs = 0; + int fields = 0; + while (tab.find()) { + final String layout = tab.group(1); + final List files = layouts(layout); + assertTrue("no layout named " + layout, !files.isEmpty()); + final Matcher id = FIELD_ID.matcher(tab.group(2)); + tabs++; + while (id.find()) { + fields++; + for (final File file : files) { + assertTrue(file.getName() + " has no " + id.group(1), + declaredIds(file).contains(id.group(1))); + } + } + } + assertTrue("parsed " + tabs + " tabs", tabs > 10); + assertTrue("parsed " + fields + " fields", fields > 80); + } +} From 684a35b6d85fbbcd29d11dd059907b2d24e78e32 Mon Sep 17 00:00:00 2001 From: Xavier Roche Date: Wed, 12 Aug 2026 22:46:53 +0200 Subject: [PATCH 2/3] Cut the test's header to the one fact a reader needs Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Xavier Roche --- .../test/java/com/httrack/android/OptionsTabFieldsTest.java | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/app/src/test/java/com/httrack/android/OptionsTabFieldsTest.java b/app/src/test/java/com/httrack/android/OptionsTabFieldsTest.java index cbdf5345..b63f83b8 100644 --- a/app/src/test/java/com/httrack/android/OptionsTabFieldsTest.java +++ b/app/src/test/java/com/httrack/android/OptionsTabFieldsTest.java @@ -15,9 +15,8 @@ import org.junit.Test; /** - * A tab looks its declared fields up in its own inflated layout, and - * WidgetDataExchange throws on a view it cannot find, so an id left behind by a - * field that moved to another tab crashes that tab as soon as it is left. + * A tab's declared field must be in that tab's own layout: leaving the tab + * looks it up, and WidgetDataExchange throws on a view it cannot find. */ public class OptionsTabFieldsTest { /** @ActivityId + @Fields of one tab, as declared in the source. */ From d90865762573ef85771c0abdce232513be5f6c94 Mon Sep 17 00:00:00 2001 From: Xavier Roche Date: Wed, 12 Aug 2026 22:49:19 +0200 Subject: [PATCH 3/3] Close the tab-field test's blind spots Check the other direction too: a view the mapper knows but no tab claims is never saved, and nothing at runtime says so. A tab whose annotations the pattern cannot read used to yield no ids and pass; it now fails. The layout walk gets a second user, so it moves to TestSources. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Xavier Roche --- .../android/NumericFieldInputTypeTest.java | 31 +---- .../httrack/android/OptionsTabFieldsTest.java | 116 ++++++++++-------- .../java/com/httrack/android/TestSources.java | 58 +++++++++ 3 files changed, 125 insertions(+), 80 deletions(-) create mode 100644 app/src/test/java/com/httrack/android/TestSources.java diff --git a/app/src/test/java/com/httrack/android/NumericFieldInputTypeTest.java b/app/src/test/java/com/httrack/android/NumericFieldInputTypeTest.java index d608f4d8..b2a53bde 100644 --- a/app/src/test/java/com/httrack/android/NumericFieldInputTypeTest.java +++ b/app/src/test/java/com/httrack/android/NumericFieldInputTypeTest.java @@ -37,35 +37,6 @@ public class NumericFieldInputTypeTest { { "editMinTransferRate", "J" }, { "editProxyPort", "P" }, { "editSingleFileMaxSize", "--single-file-max-size" } }; - /* Unit tests run with the app project as working directory. */ - private static File resDir() { - for (final String path : new String[] { "src/main/res", - "app/src/main/res" }) { - final File dir = new File(path); - if (dir.isDirectory()) { - return dir; - } - } - throw new IllegalStateException("no res directory below " - + new File(".").getAbsolutePath()); - } - - /* Every layout, qualified variants such as layout-land/ included. */ - private static Set layouts() { - final Set files = new HashSet(); - for (final File dir : resDir().listFiles()) { - if (!dir.isDirectory() || !dir.getName().startsWith("layout")) { - continue; - } - for (final File file : dir.listFiles()) { - if (file.getName().endsWith(".xml")) { - files.add(file); - } - } - } - return files; - } - private static String option(final String id) { for (final String[] field : NUMERIC_FIELDS) { if (field[0].equals(id)) { @@ -82,7 +53,7 @@ public void numericFieldsOfferADecimalPointOnlyWhereTheEngineTakesOne() factory.setNamespaceAware(true); final DocumentBuilder builder = factory.newDocumentBuilder(); final Set seen = new HashSet(); - for (final File layout : layouts()) { + for (final File layout : TestSources.layouts()) { final Document doc = builder.parse(layout); final NodeList nodes = doc.getElementsByTagName("*"); for (int i = 0; i < nodes.getLength(); i++) { diff --git a/app/src/test/java/com/httrack/android/OptionsTabFieldsTest.java b/app/src/test/java/com/httrack/android/OptionsTabFieldsTest.java index b63f83b8..55b391cc 100644 --- a/app/src/test/java/com/httrack/android/OptionsTabFieldsTest.java +++ b/app/src/test/java/com/httrack/android/OptionsTabFieldsTest.java @@ -1,14 +1,16 @@ package com.httrack.android; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; import java.io.File; import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Paths; import java.util.ArrayList; import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -26,66 +28,80 @@ public class OptionsTabFieldsTest { private static final Pattern FIELD_ID = Pattern.compile("R\\.id\\.(\\w+)"); - /* Unit tests run with the app project as working directory. */ - private static File dir(final String path) { - for (final String prefix : new String[] { "", "app/" }) { - final File dir = new File(prefix + path); - if (dir.isDirectory()) { - return dir; - } + private static final Pattern LAYOUT_ID = Pattern + .compile("android:id=\"@\\+?id/(\\w+)\""); + + private static List ids(final Pattern pattern, final String text) { + final List ids = new ArrayList(); + final Matcher m = pattern.matcher(text); + while (m.find()) { + ids.add(m.group(1)); } - throw new IllegalStateException("no " + path + " below " - + new File(".").getAbsolutePath()); + return ids; } - /* Every qualified variant of LAYOUT, all of which may be inflated. */ - private static List layouts(final String layout) { - final List files = new ArrayList(); - for (final File res : dir("src/main/res").listFiles()) { - if (!res.isDirectory() || !res.getName().startsWith("layout")) { - continue; - } - final File file = new File(res, layout + ".xml"); - if (file.isFile()) { - files.add(file); - } + /** Layout of each option tab, with the field ids that tab declares. */ + private static Map> tabs() throws IOException { + final Map> tabs = new LinkedHashMap>(); + final Matcher tab = TAB.matcher(TestSources.javaSource("OptionsActivity")); + while (tab.find()) { + final List fields = ids(FIELD_ID, tab.group(2)); + // An annotation order this pattern cannot read would yield none. + assertFalse("no field parsed for " + tab.group(1), fields.isEmpty()); + assertFalse("twice: " + tab.group(1), tabs.containsKey(tab.group(1))); + tabs.put(tab.group(1), fields); } - return files; + assertTrue("parsed " + tabs.size() + " tabs", tabs.size() > 10); + return tabs; } - private static Set declaredIds(final File layout) throws IOException { - final Set ids = new HashSet(); - final Matcher m = Pattern.compile("android:id=\"@\\+?id/(\\w+)\"").matcher( - new String(Files.readAllBytes(layout.toPath()), "UTF-8")); - while (m.find()) { - ids.add(m.group(1)); + @Test + public void everyTabFieldLivesInThatTabsLayout() throws IOException { + for (final Map.Entry> tab : tabs().entrySet()) { + final List files = TestSources.layouts(tab.getKey()); + assertFalse("no layout named " + tab.getKey(), files.isEmpty()); + for (final File file : files) { + final Set declared = new HashSet(ids(LAYOUT_ID, + TestSources.read(file))); + for (final String field : tab.getValue()) { + assertTrue(file.getName() + " has no " + field, + declared.contains(field)); + } + } } - return ids; } + /* The other direction: a view the mapper knows but no tab claims is never + saved, and nothing at runtime says so. */ @Test - public void everyTabFieldLivesInThatTabsLayout() throws IOException { - final String src = new String(Files.readAllBytes(Paths - .get(dir("src/main/java").getPath(), - "com/httrack/android/OptionsActivity.java")), "UTF-8"); - final Matcher tab = TAB.matcher(src); - int tabs = 0; - int fields = 0; - while (tab.find()) { - final String layout = tab.group(1); - final List files = layouts(layout); - assertTrue("no layout named " + layout, !files.isEmpty()); - final Matcher id = FIELD_ID.matcher(tab.group(2)); - tabs++; - while (id.find()) { - fields++; - for (final File file : files) { - assertTrue(file.getName() + " has no " + id.group(1), - declaredIds(file).contains(id.group(1))); + public void everyMappedOptionViewIsClaimedByATab() throws IOException { + final Set mapped = new HashSet(ids(FIELD_ID, + TestSources.javaSource("OptionsMapper"))); + assertTrue("parsed " + mapped.size() + " mapped ids", mapped.size() > 80); + final Map> tabs = tabs(); + int claimed = 0; + for (final File file : TestSources.layouts()) { + if (!file.getName().startsWith("activity_options_")) { + continue; + } + final String layout = file.getName().replaceFirst("\\.xml$", ""); + for (final String id : ids(LAYOUT_ID, TestSources.read(file))) { + if (!mapped.contains(id)) { + continue; + } + final List owners = new ArrayList(); + for (final Map.Entry> tab : tabs.entrySet()) { + if (tab.getValue().contains(id)) { + owners.add(tab.getKey()); + } } + assertEquals(id + " in " + layout + " claimed by " + owners, 1, + owners.size()); + assertEquals(id + " is claimed by " + owners.get(0), layout, + owners.get(0)); + claimed++; } } - assertTrue("parsed " + tabs + " tabs", tabs > 10); - assertTrue("parsed " + fields + " fields", fields > 80); + assertTrue("checked " + claimed + " views", claimed > 50); } } diff --git a/app/src/test/java/com/httrack/android/TestSources.java b/app/src/test/java/com/httrack/android/TestSources.java new file mode 100644 index 00000000..8b49e69b --- /dev/null +++ b/app/src/test/java/com/httrack/android/TestSources.java @@ -0,0 +1,58 @@ +package com.httrack.android; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.List; + +/** Checked-in sources the tests read; they run with the app project as + * working directory. */ +final class TestSources { + private TestSources() { + } + + private static File dir(final String path) { + for (final String prefix : new String[] { "", "app/" }) { + final File dir = new File(prefix + path); + if (dir.isDirectory()) { + return dir; + } + } + throw new IllegalStateException("no " + path + " below " + + new File(".").getAbsolutePath()); + } + + /** Every layout, qualified variants such as layout-land/ included. */ + static List layouts() { + return layouts(null); + } + + /** Every variant of NAME, all of which may be inflated; every layout when + * NAME is null. */ + static List layouts(final String name) { + final List files = new ArrayList(); + for (final File res : dir("src/main/res").listFiles()) { + if (!res.isDirectory() || !res.getName().startsWith("layout")) { + continue; + } + for (final File file : res.listFiles()) { + if (name == null ? file.getName().endsWith(".xml") + : file.getName().equals(name + ".xml")) { + files.add(file); + } + } + } + return files; + } + + static String read(final File file) throws IOException { + return new String(Files.readAllBytes(file.toPath()), "UTF-8"); + } + + /** Source of the com.httrack.android class NAME. */ + static String javaSource(final String name) throws IOException { + return read(new File(dir("src/main/java"), "com/httrack/android/" + name + + ".java")); + } +}