🏠 Home 

HN Blacklist

Hide Hacker News submissions from sources you don't want to see


Install this script?
// ==UserScript==
// @name         HN Blacklist
// @author       booleandilemma
// @description  Hide Hacker News submissions from sources you don't want to see
// @homepageURL  https://greasyfork.org/en/scripts/427213-hn-blacklist
// @match        https://news.ycombinator.com/
// @match        https://news.ycombinator.com/news*
// @version      3.2.0
// @grant        GM.getValue
// @grant        GM.setValue
// @license      GPL-3.0
// @namespace https://greasyfork.org/users/777592
// ==/UserScript==
"use strict";
const UserScriptName = "HN Blacklist";
const UserScriptVersion = "3.2.0";
function getBlacklist(filterText) {
const blacklist = new Set();
if (filterText == null) {
return blacklist;
}
const filters = filterText.split("\n");
for (let i = 0; i < filters.length; i++) {
const filter = filters[i].trim();
if (filter !== "" && !filter.startsWith("#")) {
blacklist.add(filter);
}
}
return blacklist;
}
// eslint-disable-next-line no-unused-vars
async function main() {
const startTime = performance.now();
const logger = new Logger();
const pageEngine = new PageEngine(logger);
const tester = new Tester();
const pageEngineTester = new PageEngineTests(pageEngine);
const testResults = tester.runTests(pageEngineTester);
logger.logInfo(testResults.summaryForLogging);
/* eslint-disable no-undef */
const filterText = (await GM.getValue("filters")) ?? "";
const filterEvenWithTestFailures = await GM.getValue(
"filterEvenWithTestFailures",
);
/* eslint-enable no-undef */
testResults.filterEvenWithTestFailures = filterEvenWithTestFailures;
const blacklist = getBlacklist(filterText);
const blacklister = new Blacklister(pageEngine, blacklist, logger);
blacklister.warnAboutInvalidBlacklistEntries();
blacklister.displayUI(testResults, filterText, filterEvenWithTestFailures);
let filterResults;
if (filterEvenWithTestFailures || testResults.failCount === 0) {
filterResults = blacklister.filterSubmissions();
} else {
filterResults = new FilterResults();
}
const timeTaken = performance.now() - startTime;
blacklister.displayResults(timeTaken, filterResults, testResults);
}
/**
* This defines an object for orchestrating the high-level filtering logic.
* It also handles user input and displaying results.
*/
class Blacklister {
/**
* Builds a list of entries from user input.
* @param {PageEngine} pageEngine The page engine is responsible for low-level interaction with HN.
* @param {set} blacklistInput A set containing the things to filter on.
* @param {Logger} logger The logger to use.
*/
constructor(pageEngine, blacklistInput, logger) {
this.pageEngine = pageEngine;
this.blacklistEntries = this.buildEntries(blacklistInput);
this.logger = logger;
}
/**
* Builds a list of entries from user input.
* @param {set} blacklistInput A set containing the things to filter on.
* @returns {Entry[]} An array of entries.
*/
buildEntries(blacklistInput) {
const entries = [];
blacklistInput.forEach((input) => {
if (input != null) {
entries.push(new Entry(input));
}
});
return entries;
}
/**
* Warns the user about invalid entries.
*/
warnAboutInvalidBlacklistEntries() {
this.blacklistEntries.forEach((entry) => {
if (!entry.isValid) {
this.logger.logError(
`"${entry.text}" is an invalid entry and will be skipped. ` +
`Entries must begin with "source:", "title:", or "user:".`,
);
}
});
}
/**
* Filters out (i.e. deletes) all submissions on the
* current HN page matching one or more provided entries.
* After filtering is performed, the page is reindexed.
* See the reindexSubmissions function of PageEngine for details.
* @returns {FilterResults} An object containing how many submissions were filtered out.
*/
filterSubmissions() {
const topRank = this.pageEngine.getTopRank();
const validEntries = this.blacklistEntries.filter((e) => e.isValid);
const submissionsFilteredBySource =
this.pageEngine.filterSubmissionsBySource(validEntries);
const submissionsFilteredByTitle =
this.pageEngine.filterSubmissionsByTitle(validEntries);
const submissionsFilteredByUser =
this.pageEngine.filterSubmissionsByUser(validEntries);
const filterResults = new FilterResults();
filterResults.submissionsFilteredBySource = submissionsFilteredBySource;
filterResults.submissionsFilteredByTitle = submissionsFilteredByTitle;
filterResults.submissionsFilteredByUser = submissionsFilteredByUser;
if (filterResults.getTotalSubmissionsFilteredOut() > 0) {
this.logger.logInfo("Reindexing submissions");
this.pageEngine.reindexSubmissions(topRank);
} else {
this.logger.logInfo("Nothing filtered");
}
return filterResults;
}
displayUI(testResults, filterText, filterEvenWithTestFailures) {
const hnBlacklistTable = document.getElementById("hnBlacklist");
if (hnBlacklistTable != null) {
hnBlacklistTable.remove();
}
const statsRow = document.createElement("tr");
let testResultsMessage = `Test Results: ${testResults.testCount - testResults.failCount}/${testResults.testCount} Passed in ${testResults.timeTaken} ms.`;
if (testResults.failCount > 0) {
testResultsMessage += " Check the log for details.";
}
const stats = `
<td>
<table id="hnBlacklist">
<tbody>
<tr>
<td>
<p style="text-decoration:underline;">
<a href="https://greasyfork.org/en/scripts/427213-hn-blacklist">${UserScriptName} ${UserScriptVersion}</a>
</p>
</td>
</tr>
<tr>
<td>
<textarea id="filters" style="width:300px;height:150px">${filterText}</textarea>
</td>
</tr>
<tr>
<td>
<input id="chkfilterEvenWithTestFailures" type="checkbox">Filter even with test failures</input>
</td>
</tr>
<tr>
<td>
<button id="btnSaveFilters">Save</button>
</td>
</tr>
<tr>
<td>
<p id="filteredResults"></p>
</td>
</tr>
<tr>
<td id="validityResults"></td>
</tr>
<tr>
<td id="testResults">${testResultsMessage}</td>
</tr>
<tr>
<td id="executionTimeResults"></td>
</tr>
</tbody>
</table>
</td>`;
statsRow.innerHTML = stats;
this.pageEngine.displayResults(statsRow);
document.getElementById("chkfilterEvenWithTestFailures").checked =
filterEvenWithTestFailures;
document.getElementById("btnSaveFilters").onclick = this.#saveInputsAsync;
}
/**
* Displays results to the user.
* @param {number} timeTaken The time the script took to execute.
* @param {FilterResults} filterResults High-level results of what was done.
* @param {TestResults} testResults A summary of test results.
*/
displayResults(timeTaken, filterResults, testResults) {
let entryValidityMessage = "Entry Validity: ";
if (this.blacklistEntries.length > 0) {
const invalidEntriesExist = this.blacklistEntries.some((e) => !e.isValid);
const errorMessage =
"One or more of your entries is invalid. Check the log for details";
entryValidityMessage += invalidEntriesExist
? errorMessage
: "All entries valid";
} else {
entryValidityMessage += "No entries supplied";
}
let filteredMessage = "Filtered: ";
if (testResults.failCount > 0) {
if (!testResults.filterEvenWithTestFailures) {
filteredMessage += "One or more tests failed - did not try to filter";
} else {
filteredMessage += `${filterResults.submissionsFilteredBySource} by source, ${filterResults.submissionsFilteredByTitle} by title, ${filterResults.submissionsFilteredByUser} by user`;
}
} else {
filteredMessage += `${filterResults.submissionsFilteredBySource} by source, ${filterResults.submissionsFilteredByTitle} by title, ${filterResults.submissionsFilteredByUser} by user`;
}
document.getElementById("filteredResults").innerText = filteredMessage;
document.getElementById("validityResults").innerText = entryValidityMessage;
document.getElementById("executionTimeResults").innerText =
`Execution Time: ${timeTaken} ms`;
}
async #saveInputsAsync() {
const filtersElement = document.getElementById("filters");
const filterText = filtersElement.value.trim();
const chkfilterEvenWithTestFailuresElement = document.getElementById(
"chkfilterEvenWithTestFailures",
);
/* eslint-disable no-undef */
await GM.setValue("filters", filterText);
await GM.setValue(
"filterEvenWithTestFailures",
chkfilterEvenWithTestFailuresElement.checked,
);
/* eslint-enable no-undef */
alert("Filters saved! Please refresh the page.");
}
}
/**
* An entry for filtering submissions.
*/
class Entry {
/**
* Creates an entry.
* @param {string} input Something the user wants to filter by.
* It can begin with "source:", "title:", or "user:".
*/
constructor(input) {
/**
* isValid indicates whether or not the entry is valid.
* @type {boolean}
* @public
*/
this.isValid = null;
/**
* prefix indicates the type of thing to filter by. It can be "source:", "title:", or "user:".
* @type {string}
* @public
*/
this.prefix = null;
/**
* text indicates the value of the source, title, or user to filter by.
* @type {string}
* @public
*/
this.text = null;
/**
* starCount indicates the number of stars (asterisks) of the source to filter by.
* @type {number}
* @public
*/
this.starCount = null;
/**
* isExclusion indicates whether or not this source should be excluded from blacklisting.
* @type {boolean}
* @public
*/
this.isExclusion = null;
this.#buildEntry(input);
}
/**
* Determines if the input is valid.
* @param {string} input Something the user wants to filter by.
* @param {number} starCount The number of stars in the input.
* It can begin with "source:", "title:", or "user:".
* @returns {boolean} A boole indicating whether or not the entry is valid.
*/
#isValidInput(input, starCount) {
if (input.startsWith("source:") && this.#isValidSource(input, starCount)) {
return true;
}
if (input.startsWith("title:") && this.#isValidTitle(input)) {
return true;
}
if (input.startsWith("user:") && this.#isValidUser(input)) {
return true;
}
return false;
}
#isValidSource(input, starCount) {
if (this.#getCharCount(input, "!") > 1) {
return false;
}
if (input.includes("!") && input.includes("*")) {
return false;
}
if (input.includes("!") && !input.startsWith("source:!")) {
return false;
}
return this.#hasValidStars(input, starCount);
}
#isValidTitle(input) {
return !input.includes("!") && !input.includes("*");
}
#isValidUser(input) {
return !input.includes("!") && !input.includes("*");
}
#hasValidStars(input, starCount) {
input = input.replace("source:", "");
switch (starCount) {
case 0:
return true;
case 1:
return input.startsWith("*") || input.endsWith("*");
case 2:
return input.startsWith("*") && input.endsWith("*");
default:
return false;
}
}
#getCharCount(input, char) {
let starCount = 0;
for (const c of input) {
if (c === char) {
starCount++;
}
}
return starCount;
}
#isExclusion(input) {
return input.startsWith("source:!");
}
#buildEntry(input) {
this.starCount = this.#getCharCount(input, "*");
this.isExclusion = this.#isExclusion(input);
this.isValid = this.#isValidInput(input, this.starCount);
if (this.isValid) {
const prefix = input.substring(0, input.indexOf(":"));
const text = input.substring(input.indexOf(":") + 1);
this.prefix = prefix;
this.text = text;
} else {
this.prefix = null;
this.text = input;
}
}
}
/**
* A high-level summary of the results of what was done.
*/
class FilterResults {
constructor() {
/**
* submissionsFilteredBySource indicates the number of submissions filtered by source.
* @type {number}
* @public
*/
this.submissionsFilteredBySource = 0;
/**
* submissionsFilteredByTitle indicates the number of submissions filtered by title.
* @type {number}
* @public
*/
this.submissionsFilteredByTitle = 0;
/**
* submissionsFilteredByUser indicates the number of submissions filtered by user.
* @type {number}
* @public
*/
this.submissionsFilteredByUser = 0;
}
/**
* A function for getting the total number of submissions filtered out.
* @returns {number} The total number of submissions filtered by all categories.
*/
getTotalSubmissionsFilteredOut() {
return (
this.submissionsFilteredBySource +
this.submissionsFilteredByTitle +
this.submissionsFilteredByUser
);
}
}
class Logger {
/**
* Logs an info message to the console.
* @param {string} message Specifies the message to log.
*/
logInfo(message) {
console.info(`${UserScriptName}: ${message}`);
}
/**
* Logs a warning message to the console.
* @param {string} message Specifies the message to log.
*/
logWarning(message) {
console.warn(`${UserScriptName}: ${message}`);
}
/**
* Logs an error message to the console.
* @param {string} message Specifies the message to log.
*/
logError(message) {
console.error(`${UserScriptName}: ${message}`);
}
}
/**
* This defines an object for interacting with the HN page itself, at a low-level.
*/
class PageEngine {
constructor(logger) {
this.logger = logger;
}
/**
* Get the thing holding the list of submissions.
*/
getSubmissionTable() {
const submissions = this.getSubmissions();
if (submissions == null || submissions.length === 0) {
return null;
}
return submissions[0].parentElement;
}
/**
* Get the list of submissions.
*/
getSubmissions() {
return document.querySelectorAll(".athing");
}
/**
* Updates the specified submission to the specified rank.
* @param {?object} submission Specifies the HN submission.
* @param {number} newRank Specifies the new rank to set on the specified submission.
*/
setRank(submission, newRank) {
if (submission === null) {
this.logger.logWarning("submission is null");
return;
}
let titleIndex = 0;
for (let i = 0; i < submission.childNodes.length; i++) {
const childNode = submission.childNodes[i];
if (childNode.className === "title") {
titleIndex++;
}
if (titleIndex === 1) {
const rank = childNode.innerText;
if (rank === null) {
this.logger.logWarning("rank is null");
return;
}
childNode.innerText = `${newRank}.`;
return;
}
}
this.logger.logWarning(`no rank found: ${JSON.stringify(submission)}`);
}
/**
* Updates the ranks of all of the remaining submissions on the current HN page.
* This function is intended to be called after the submissions have been filtered.
* This is because once the submissions are filtered, there is a gap in the rankings.
* For example, if the 3rd submission is removed, the remaining submissions will have
* ranks of: 1, 2, 4, 5, etc.
* This function will correct the remaining submissions to have ranks of: 1, 2, 3, 4, etc.
* This is accomplished by passing in the top rank on the current HN page _before_
* any filtering is done. For example, if the current HN page is the first one,
* the top rank will be "1", and so numbering will start from 1. If the current page
* is the second one, the top rank will be "31".
* @param {number} topRank Specifies the top rank to start numbering from.
*/
reindexSubmissions(topRank) {
const submissions = this.getSubmissions();
for (let i = 0; i < submissions.length; i++) {
this.setRank(submissions[i], topRank + i);
}
}
/**
* Scans the list of submissions on the current HN page
* and returns the rank of the first submission in the list.
* @returns {?number} The rank of the first HN submission.
*/
getTopRank() {
const submissions = this.getSubmissions();
if (submissions == null) {
this.logger.logWarning("submissions are null");
return null;
}
if (submissions.length === 0) {
this.logger.logWarning("submissions are empty");
return null;
}
const topRank = this.getRank(submissions[0]);
return topRank;
}
/**
* Returns the source of the specified titleInfo.
* @param {?object} titleInfo An element containing the submission headline and source.
*/
getSource(titleInfo) {
if (titleInfo === null) {
this.logger.logWarning("titleInfo is null");
return null;
}
const titleText = titleInfo.innerText;
const lastParenIndex = titleText.lastIndexOf("(");
if (lastParenIndex < 0) {
return null;
}
const source = titleText
.substring(lastParenIndex + 1, titleText.length - 1)
.trim();
return source;
}
/**
* Returns the titleText (i.e. headline) of the specified titleInfo.
* @param {?object} titleInfo An element containing the submission headline and source.
*/
getTitleText(titleInfo) {
if (titleInfo === null) {
this.logger.logWarning("titleInfo is null");
return null;
}
const titleText = titleInfo.innerText;
const lastParenIndex = titleText.lastIndexOf("(");
if (lastParenIndex < 0) {
return titleText;
}
return titleText.substring(0, lastParenIndex);
}
/**
* @param {?object} submission Specifies the HN submission.
* @returns {?number} The "rank" of an HN submission.
* The rank is defined as the number to the far left of the submission.
*/
getRank(submission) {
if (submission === null) {
this.logger.logWarning("submission is null");
return null;
}
let titleIndex = 0;
for (let i = 0; i < submission.childNodes.length; i++) {
const childNode = submission.childNodes[i];
if (childNode.className === "title") {
titleIndex++;
}
if (titleIndex === 1) {
const rank = childNode.innerText;
if (rank === null) {
this.logger.logWarning("rank is null");
return null;
}
return parseInt(rank.replace(".", "").trim(), 10);
}
}
this.logger.logWarning(`no rank found: ${JSON.stringify(submission)}`);
return null;
}
/**
* Returns the titleInfo of the specified submission.
* This is an element containing the headline and the source
* of the submission.
* @param {?object} submission Specifies the HN submission.
*/
getTitleInfo(submission) {
if (submission === null) {
this.logger.logWarning("submission is null");
return null;
}
let titleIndex = 0;
for (let i = 0; i < submission.childNodes.length; i++) {
const childNode = submission.childNodes[i];
if (childNode.className === "title") {
titleIndex++;
}
if (titleIndex === 2) {
return childNode;
}
}
this.logger.logWarning(`no titleInfo found: ${JSON.stringify(submission)}`);
return null;
}
/**
* Returns the submitter of the specified submission.
* @param {?object} submission Specifies the HN submission.
* @returns {?string} the username of the submitter.
*/
getSubmitter(submission) {
if (submission === null) {
this.logger.logWarning("submission is null");
return null;
}
const { nextSibling } = submission;
if (nextSibling === null) {
// TODO: this might be a bug
const rank = this.getRank(submission);
this.logger.logWarning(`nextSibling is null. rank is: ${rank}`);
return null;
}
const userLink = nextSibling.querySelector(".hnuser");
if (userLink == null) {
const rank = this.getRank(submission);
this.logger.logWarning(`userLink is null. rank is: ${rank}`);
return null;
}
const hrefUser = userLink.getAttribute("href");
if (hrefUser == null) {
this.logger.logWarning("hrefUser is null");
return null;
}
return hrefUser.replace("user?id=", "");
}
/**
* Returns an object representing the different parts of the specified submission.
* These are: title, source, rank, and rowIndex.
* @param {?object} submission Specifies the HN submission.
*/
getSubmissionInfo(submission) {
if (submission === null) {
return null;
}
const titleInfo = this.getTitleInfo(submission);
const rank = this.getRank(submission);
const submitter = this.getSubmitter(submission);
const titleText = this.getTitleText(titleInfo);
const source = this.getSource(titleInfo);
const { rowIndex } = submission;
return {
title: titleText,
source,
submitter,
rank,
rowIndex,
};
}
/**
* Filters out (i.e. deletes) all submissions on the
* current HN page with a domain source contained in the specified blacklist.
* @param {Entry[]} blacklistEntries A list containing entries to filter on.
* @returns {number} A number indicating how many submissions were filtered out.
*/
filterSubmissionsBySource(blacklistEntries) {
const submissions = this.getSubmissions();
const submissionTable = this.getSubmissionTable();
let submissionsFiltered = 0;
function shouldFilter(source, entry) {
const entryText = entry.text.toLowerCase();
switch (entry.starCount) {
case 0:
return source === entryText;
case 1:
if (entryText.endsWith("*")) {
return source.startsWith(entryText.replace("*", ""));
}
return source.endsWith(entryText.replace("*", ""));
case 2:
return source.includes(entryText.replaceAll("*", ""));
default:
this.logger.logError(`Invalid number of asterisks in ${entryText}`);
return false;
}
}
const exclusions = new Set();
blacklistEntries.forEach((entry) => {
if (entry.isExclusion) {
exclusions.add(entry.text.toLowerCase().substring(1));
}
});
blacklistEntries.forEach((entry) => {
if (entry.prefix !== "source" || entry.isExclusion) {
return;
}
for (let i = 0; i < submissions.length; i++) {
const submissionInfo = this.getSubmissionInfo(submissions[i]);
if (submissionInfo.source == null) {
this.logger.logWarning(`source is null. rank is ${i}`);
continue;
}
if (exclusions.has(submissionInfo.source)) {
this.logger.logInfo(
`Source excluded from blacklisting - ${submissionInfo.source}`,
);
continue;
}
if (shouldFilter.call(this, submissionInfo.source, entry)) {
this.logger.logInfo(
`Source blacklisted - removing ${JSON.stringify(submissionInfo)}`,
);
// Delete the submission
submissionTable.deleteRow(submissionInfo.rowIndex);
// Delete the submission comments link
submissionTable.deleteRow(submissionInfo.rowIndex);
// Delete the spacer row after the submission
submissionTable.deleteRow(submissionInfo.rowIndex);
submissionsFiltered++;
}
}
});
return submissionsFiltered;
}
/**
* Filters out (i.e. deletes) all submissions on the
* current HN page with a title substring contained in the specified blacklist.
* @param {Entry[]} blacklistEntries A list containing entries to filter on.
* @returns {number} A number indicating how many submissions were filtered out.
*/
filterSubmissionsByTitle(blacklistEntries) {
const submissions = this.getSubmissions();
const submissionTable = this.getSubmissionTable();
let submissionsFiltered = 0;
blacklistEntries.forEach((entry) => {
if (entry.prefix !== "title") {
return;
}
for (let j = 0; j < submissions.length; j++) {
const submissionInfo = this.getSubmissionInfo(submissions[j]);
if (
submissionInfo.title.toLowerCase().includes(entry.text.toLowerCase())
) {
this.logger.logInfo(
`Title keyword blacklisted - removing ${JSON.stringify(submissionInfo)}`,
);
// Delete the submission
submissionTable.deleteRow(submissionInfo.rowIndex);
// Delete the submission comments link
submissionTable.deleteRow(submissionInfo.rowIndex);
// Delete the spacer row after the submission
submissionTable.deleteRow(submissionInfo.rowIndex);
submissionsFiltered++;
}
}
});
return submissionsFiltered;
}
/**
* Filters out (i.e. deletes) all submissions on the
* current HN page submitted by the specified user.
* @param {Entry[]} blacklistEntries A list containing entries to filter on.
* @returns {number} A number indicating how many submissions were filtered out.
*/
filterSubmissionsByUser(blacklistEntries) {
const submissions = this.getSubmissions();
const submissionTable = this.getSubmissionTable();
let submissionsFiltered = 0;
blacklistEntries.forEach((entry) => {
if (entry.prefix !== "user") {
return;
}
for (let j = 0; j < submissions.length; j++) {
const submissionInfo = this.getSubmissionInfo(submissions[j]);
if (
submissionInfo.submitter != null &&
submissionInfo.submitter.toLowerCase() === entry.text.toLowerCase()
) {
this.logger.logInfo(
`User blacklisted - removing ${JSON.stringify(submissionInfo)}`,
);
// Delete the submission
submissionTable.deleteRow(submissionInfo.rowIndex);
// Delete the submission comments link
submissionTable.deleteRow(submissionInfo.rowIndex);
// Delete the spacer row after the submission
submissionTable.deleteRow(submissionInfo.rowIndex);
submissionsFiltered++;
}
}
});
return submissionsFiltered;
}
displayResults(resultsRow) {
const mainTable = document.getElementById("hnmain");
/*
* HN adds an extra child to the mainTable,
* so we have to do this to get the tbody.
* I'm not sure why HN does this.
* This assumes the tbody will be the last child.
*/
const childCount = mainTable.childNodes.length;
const tbody = mainTable.childNodes[childCount - 1];
tbody.appendChild(resultsRow);
}
}
/**
* This class contains several tests for testing the correctness of the PageEngine.
* As the PageEngine is the closest code in this userscript to HN,
* it's most susceptible to breaking if the HN developers change something.
* Therefore, it's good to have tests for all of its functionality.
*/
class PageEngineTests {
constructor(pageEngine) {
this.pageEngine = pageEngine;
}
test_getSubmissionTable_ableToGetSubmissionTable(tester) {
// Arrange
// Act
let table;
try {
table = this.pageEngine.getSubmissionTable();
} catch {
// Empty
}
// Assert
if (table == null) {
tester.failWith({
message: "Unable to obtain submission table",
});
}
}
test_getSubmissions_numberOfSubmissionsIsCorrect(tester) {
// Arrange
const expectedLength = 30;
// Act
const { submissions, result } = this.getSubmissionsWithResult();
// Assert
if (submissions == null) {
tester.failWith(result);
}
if (submissions.length !== expectedLength) {
tester.failWith({
message: `Submissions length is wrong. expected ${expectedLength}, got ${submissions.length}`,
});
}
}
test_getRank_ableToGetRank(tester) {
// Arrange
const { submissions, result } = this.getSubmissionsWithResult();
if (submissions == null) {
tester.failWith(result);
}
// Arbitrarily testing the 5th submission.
if (submissions.length < 5) {
tester.failWith({
message: "Submissions length less than 5, can't get a rank",
});
}
// Act
let firstRankOnPage = null;
try {
firstRankOnPage = this.pageEngine.getRank(submissions[0]);
} catch {
// Empty
}
// Assert
if (firstRankOnPage == null) {
tester.failWith({
message: "First submission rank is null",
});
}
let fifthRank = null;
try {
fifthRank = this.pageEngine.getRank(submissions[4]);
} catch {
// Empty
}
if (fifthRank == null) {
tester.failWith({
message: "Fifth submission rank is null",
});
}
/*
* We offset the rank like this so that this test will work
* on any submissions page.
*/
if (fifthRank !== firstRankOnPage + 4) {
tester.failWith({
message: "Unable to obtain submission rank",
});
}
}
test_getTopRank_ableToGetTopRank(tester) {
// Arrange
// Act
let topRank = null;
try {
topRank = this.pageEngine.getTopRank();
} catch {
// Empty
}
// Assert
if (topRank == null) {
tester.failWith({
message: "Unable to get top rank",
});
}
}
test_getSubmitter_ableToGetSubmitter(tester) {
// Arrange
const { submissions, result } = this.getSubmissionsWithResult();
if (submissions == null) {
tester.failWith(result);
}
// Arbitrarily testing the 5th submission.
if (submissions.length < 5) {
tester.failWith({
message: "Submissions length less than 5, can't get a rank",
});
}
// Act
let submitter = null;
try {
submitter = this.pageEngine.getSubmitter(submissions[4]);
} catch {
// Empty
}
// Assert
if (submitter == null || submitter.trim() === "") {
tester.failWith({
message: "Couldn't get submitter",
});
}
}
test_getTitleInfo_ableToGetTitleInfo(tester) {
// Arrange
const submissionsAndResult = this.getSubmissionsWithResult();
const submissions = submissionsAndResult.submissions;
if (submissions == null) {
tester.failWith(submissionsAndResult.result);
}
// Arbitrarily testing the 5th submission.
if (submissions.length < 5) {
tester.failWith({
message: "Submissions length less than 5, can't get a rank",
});
}
// Act
const { titleInfo, result } = this.getTitleInfoWithResult(submissions[4]);
// Assert
if (titleInfo == null) {
tester.failWith(result);
}
}
test_getTitleText_ableToGetTitleText(tester) {
// Arrange
const submissionsAndResult = this.getSubmissionsWithResult();
const submissions = submissionsAndResult.submissions;
if (submissions == null) {
tester.failWith(submissionsAndResult.result);
}
// Arbitrarily testing the 5th submission.
if (submissions.length < 5) {
tester.failWith({
message: "Submissions length less than 5, can't get a rank",
});
}
const { titleInfo, result } = this.getTitleInfoWithResult(submissions[4]);
if (titleInfo == null) {
tester.failWith(result);
}
// Act
const titleText = this.pageEngine.getTitleText(titleInfo);
// Assert
if (titleText == null || titleText.trim() === "") {
tester.failWith({
message: "Unable to get title text on title info",
});
}
}
test_getSource_ableToGetSource(tester) {
// Arrange
const submissionsAndResult = this.getSubmissionsWithResult();
const submissions = submissionsAndResult.submissions;
if (submissions == null) {
tester.failWith(submissionsAndResult.result);
}
// Arbitrarily testing the 5th submission.
if (submissions.length < 5) {
tester.failWith({
message: "Submissions length less than 5, can't get a rank",
});
}
const { titleInfo, result } = this.getTitleInfoWithResult(submissions[4]);
if (titleInfo == null) {
tester.failWith(result);
}
// Act
const source = this.pageEngine.getSource(titleInfo);
// Assert
if (source == null || source.trim() === "") {
tester.failWith({
message: "Unable to get source on title info",
});
}
}
getSubmissionsWithResult() {
let submissions = null;
try {
submissions = this.pageEngine.getSubmissions();
} catch {
// Empty
}
if (submissions == null) {
return {
submissions: null,
result: {
message: "Unable to obtain submission",
},
};
}
return {
submissions,
result: null,
};
}
getTitleInfoWithResult(submission) {
let titleInfo = null;
try {
titleInfo = this.pageEngine.getTitleInfo(submission);
} catch {
// Empty
}
if (titleInfo == null) {
return {
titleInfo: null,
result: {
message: "Couldn't get title info",
},
};
}
return {
titleInfo,
result: null,
};
}
}
class TestResults {
constructor() {
this.filterEvenWithTestFailures = null;
this.failCount = null;
this.testCount = null;
this.timeTaken = null;
}
}
class Tester {
runTests(testClass) {
const tests = this.#getTests(Object.getPrototypeOf(testClass));
const resultsForLogging = [];
let failCount = 0;
const startTime = performance.now();
for (let i = 0; i < tests.length; i++) {
const testResult = this.#runTest(testClass, tests[i]);
if (testResult.status !== "passed") {
failCount++;
}
resultsForLogging.push(testResult);
}
const timeTaken = performance.now() - startTime;
const testResults = new TestResults();
testResults.failCount = failCount;
testResults.testCount = tests.length;
testResults.timeTaken = timeTaken;
testResults.summaryForLogging = this.#getSummaryForLogging(
resultsForLogging,
failCount,
timeTaken,
);
return testResults;
}
failWith(result) {
result.status = "failed";
throw result;
}
#getTests(testClass) {
return Object.getOwnPropertyNames(testClass).filter((p) =>
p.startsWith("test_"),
);
}
#runTest(testClass, testToRun) {
try {
testClass[testToRun](this);
} catch (error) {
const result = {
name: testToRun,
status: error.status ?? "failed",
message: error.message,
stackTrace: error.stack,
};
return result;
}
const result = {
name: testToRun,
status: "passed",
};
return result;
}
#getSummaryForLogging(results, failCount, timeTaken) {
const testCount = results.length;
let summary;
if (failCount === 0) {
summary = `Tests Results ${testCount}/${testCount} Passed in ${timeTaken} ms`;
} else {
summary = `Tests Results ${testCount - failCount}/${testCount} Passed ${JSON.stringify(results, null, 2)} in ${timeTaken} ms`;
}
return summary;
}
}
main();