DEV: Replace ArrayProxy with tracked array for CategoryList (#35404)

Migrates category listing and related UI to tracked built-ins with an
array-like proxy for improved reactivity and modernization.

- Introduce LegacyArrayLikeObject: a Proxy over TrackedArray preserving
array semantics while allowing instance properties/methods
- Rewrite CategoryList to use an array-like object; add tracked state
(page, isLoading, fetchedLastPage, parentCategory), async list(), and
safer loadMore with error handling; deprecate legacy categories/content
usage
- Update routes/templates to pass CategoryList directly (not
.categories), switch to async/await, preserve PreloadStore behavior, and
sync TopicTrackingState with the new model
- Refactor category components (boxes, boxes-with-topics, only) to ES
getters and native array APIs; remove discourseComputed and Ember
helpers like firstObject/filterBy
- Add tests for array-like object behavior (array methods, inheritance,
plugin API modifyClass), CategoryList fetching/parent filtering/stat
rendering/pagination, and UI reactivity
- Remove ArrayProxy and other deprecated patterns
This commit is contained in:
Sérgio Saquetim
2025-10-22 10:18:21 -03:00
committed by GitHub
parent f9692f6512
commit 601310c042
11 changed files with 1535 additions and 99 deletions
@@ -10,7 +10,6 @@ import PluginOutlet from "discourse/components/plugin-outlet";
import categoryColorVariable from "discourse/helpers/category-color-variable";
import { categoryBadgeHTML } from "discourse/helpers/category-link";
import lazyHash from "discourse/helpers/lazy-hash";
import discourseComputed from "discourse/lib/decorators";
@tagName("section")
@classNameBindings(
@@ -18,9 +17,8 @@ import discourseComputed from "discourse/lib/decorators";
"anyLogos:with-logos:no-logos"
)
export default class CategoriesBoxesWithTopics extends Component {
@discourseComputed("categories.[].uploaded_logo.url")
anyLogos() {
return this.categories.any((c) => {
get anyLogos() {
return this.categories.some((c) => {
return !isEmpty(c.get("uploaded_logo.url"));
});
}
@@ -13,7 +13,6 @@ import categoryLink, {
categoryBadgeHTML,
} from "discourse/helpers/category-link";
import lazyHash from "discourse/helpers/lazy-hash";
import discourseComputed from "discourse/lib/decorators";
@tagName("section")
@classNameBindings(
@@ -22,14 +21,12 @@ import discourseComputed from "discourse/lib/decorators";
"hasSubcategories:with-subcategories"
)
export default class CategoriesBoxes extends Component {
@discourseComputed("categories.[].uploaded_logo.url")
anyLogos() {
return this.categories.any((c) => !isEmpty(c.get("uploaded_logo.url")));
get anyLogos() {
return this.categories.some((c) => !isEmpty(c.get("uploaded_logo.url")));
}
@discourseComputed("categories.[].subcategories")
hasSubcategories() {
return this.categories.any((c) => !isEmpty(c.get("subcategories")));
get hasSubcategories() {
return this.categories.some((c) => !isEmpty(c.get("subcategories")));
}
categoryName(category) {
@@ -48,11 +48,11 @@ export default class CategoriesOnly extends Component {
}
// hide in single category pages
if (categories.firstObject.parent_category_id) {
if (categories[0].parent_category_id) {
return [];
}
return categories.filterBy("hasMuted");
return categories.filter((category) => category.hasMuted);
}
@action
@@ -0,0 +1,264 @@
/**
* DEPRECATED: Do not use LegacyArrayLikeObject for new code.
*
* This class is intended ONLY for providing TrackedArray capabilities to support
* already existing classes that previously used the ArrayProxy mixin. It should not
* be used in new development.
*
* For new code, use a standard class with tracked properties and @trackedArray for the content property.
* Example:
*
* import { tracked } from '@glimmer/tracking';
* import { trackedArray } from "discourse/lib/tracked-tools";
*
* class MyArrayWrapper {
* @tracked someProp;
* @trackedArray content = [];
* }
*
* This approach provides reactivity and array capabilities without legacy proxy patterns.
*/
import EmberObject from "@ember/object";
import { TrackedArray } from "@ember-compat/tracked-built-ins";
import deprecated from "discourse/lib/deprecated";
const EMBER_OBJECT_PROPERTIES = new Set([
"constructor",
"addObserver",
"cacheFor",
"decrementProperty",
"destroy",
"get",
"getProperties",
"incrementProperty",
"init",
"notifyPropertyChange",
"removeObserver",
"set",
"setProperties",
"toString",
"toggleProperty",
"willDestroy",
"concatenatedProperties",
"isDestroyed",
"isDestroying",
"mergedProperties",
]);
const ARRAY_PROPERTIES = new Set(
[
...Object.getOwnPropertyNames(Array.prototype),
...Object.getOwnPropertySymbols(Array.prototype),
] //.filter((prop) => !EMBER_OBJECT_PROPERTIES.has(prop))
);
/**
* LegacyArrayLikeObject is an EmberObject that proxies array-like behavior to a TrackedArray,
* while exposing additional properties and methods. Access array methods via `.content`.
*
* @class LegacyArrayLikeObject
* @extends EmberObject
* @example
* const obj = LegacyArrayLikeObject.create({ content: [1,2,3], foo: 'bar' });
* obj.content.push(4); // Use .content for array operations
* obj.foo // 'bar'
*
* Note: Must be instantiated via LegacyArrayLikeObject.create().
*/
export default class LegacyArrayLikeObject extends EmberObject {
static #isConstructing = false; // to simulate a private constructor
/**
* Creates an instance of LegacyArrayLikeObject. Must be used instead of `new`.
*
* @param {Object} attrs - Properties to set on the instance. `content` must be an array.
* @returns {LegacyArrayLikeObject}
*/
static create(attrs = {}) {
LegacyArrayLikeObject.#isConstructing = true;
const { content, ...properties } = attrs;
const object = new this(content);
// on subclasses the fields are initialized after the constructor of the base class
// has run with the super() clause.
// See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes/constructor
// Because of this, to prevent the proxy from getting confused and saving field properties into
// the underlying TrackedArray, we need to set the properties after the instance has been created.
object.setProperties(properties);
return object;
}
#content;
/**
* Constructor is private. Use LegacyArrayLikeObject.create() instead.
*
* @param {Array} content - The array to wrap. Must be an array.
* @throws {TypeError} If not called via .create or if content is not an array.
* @private
*/
constructor(content = []) {
super();
if (!LegacyArrayLikeObject.#isConstructing) {
throw new TypeError(
`${this.constructor.name} is not constructable. Use the static \`${this.constructor.name}.create()\` method instead.`
);
}
LegacyArrayLikeObject.#isConstructing = false;
// Validate inputs
if (!Array.isArray(content)) {
throw new TypeError(
`${this.constructor.name}: \`.content\` must be an array`
);
}
this.#content =
content instanceof TrackedArray ? content : new TrackedArray(content);
return createProxy(this, this.#content);
}
}
/**
* Checks if a property is an EmberObject property or a custom instance property.
*
* @param {Object} instance - The object instance to check against.
* @param {string|symbol} prop - The property name or symbol.
* @returns {boolean} True if the property belongs to the instance, false otherwise.
*/
function isInstanceProperty(instance, prop) {
return (
Reflect.has(instance, prop) &&
(EMBER_OBJECT_PROPERTIES.has(prop) || !ARRAY_PROPERTIES.has(prop))
);
}
/**
* Checks if a property is an Array property.
*
* @param {string|symbol} prop - The property name or symbol.
* @returns {boolean} True if the property is an Array property, false otherwise.
*/
function isArrayProperty(prop) {
return ARRAY_PROPERTIES.has(prop);
}
/**
* Checks if a property is a numeric array index (string or number).
* Accepts numeric strings or numbers (e.g. '0', 0, '12', 12).
*
* @param {string|number} prop - The property to check.
* @returns {boolean} True if the property is a numeric index, false otherwise.
*/
function isNumericIndexProp(prop) {
// Accepts numeric strings or numbers (e.g. '0', 0, '12', 12)
return (
(typeof prop === "string" && /^\d+$/.test(prop)) ||
(typeof prop === "number" &&
Number.isFinite(prop) &&
Number.isInteger(prop))
);
}
/**
* Emits a deprecation warning for direct array property access.
*
* @param {string} instanceName - The name of the instance's constructor.
* @param {string|number|symbol} prop - The property being accessed.
* @param {boolean} [isIndex=false] - Whether the property is a numeric index.
*/
function warnArrayDeprecation(instanceName, prop, isIndex = false) {
const propName = isIndex ? `[${prop}]` : `.${prop.toString()}`;
deprecated(
`Accessing \`(${instanceName} instance)${propName}\` directly is deprecated. ` +
`Access the array directly via \`.content\` instead. ` +
`For example, use \`(${instanceName} instance).content${propName}\` instead of \`(${instanceName} instance)${propName}\`.`,
{
id: "discourse.legacy-array-like-object.proxied-array",
}
);
}
/**
* Creates a proxy that intercepts property access, forwarding instance properties to the LegacyArrayLikeObject
* and array properties to the underlying array.
* Emits deprecation warnings for direct array property access.
*
* @param {LegacyArrayLikeObject} instance - The LegacyArrayLikeObject instance.
* @param {TrackedArray} trackedItems - The underlying tracked array.
* @returns {Proxy} Proxy object that combines instance and array behaviors.
*/
function createProxy(instance, trackedItems) {
const instanceName = instance.constructor.name;
return new Proxy(trackedItems, {
get(target, prop, receiver) {
if (prop === "content") {
return target;
}
if (isInstanceProperty(instance, prop)) {
return Reflect.get(instance, prop, receiver);
}
if (isArrayProperty(prop) || isNumericIndexProp(prop)) {
warnArrayDeprecation(instanceName, prop, isNumericIndexProp(prop));
}
return Reflect.get(target, prop, receiver);
},
set(target, prop, value, receiver) {
if (prop === "content") {
throw new Error(
`You cannot override the content property of an ${instanceName}, mutate the array instead.`
);
}
if (isInstanceProperty(instance, prop)) {
return Reflect.set(instance, prop, value, receiver);
}
if (isArrayProperty(prop) || isNumericIndexProp(prop)) {
warnArrayDeprecation(instanceName, prop, isNumericIndexProp(prop));
}
return Reflect.set(target, prop, value, receiver);
},
has(target, prop) {
return Reflect.has(instance, prop) || Reflect.has(target, prop);
},
getPrototypeOf() {
return instance.constructor.prototype;
},
ownKeys(target) {
const instanceKeys = Reflect.ownKeys(instance);
const targetKeys = Reflect.ownKeys(target);
return [...new Set([...instanceKeys, ...targetKeys])];
},
defineProperty(target, prop, descriptor) {
return Reflect.defineProperty(instance, prop, descriptor);
},
deleteProperty(target, prop) {
return Reflect.deleteProperty(instance, prop);
},
getOwnPropertyDescriptor(target, prop) {
if (isInstanceProperty(instance, prop)) {
return Reflect.getOwnPropertyDescriptor(instance, prop);
}
return Reflect.getOwnPropertyDescriptor(target, prop);
},
});
}
@@ -1,142 +1,220 @@
import ArrayProxy from "@ember/array/proxy";
import { tracked } from "@glimmer/tracking";
import { ajax } from "discourse/lib/ajax";
import { bind } from "discourse/lib/decorators";
import deprecated from "discourse/lib/deprecated";
import { number } from "discourse/lib/formatter";
import LegacyArrayLikeObject from "discourse/lib/legacy-array-like-object";
import PreloadStore from "discourse/lib/preload-store";
import { trackedArray } from "discourse/lib/tracked-tools";
import Site from "discourse/models/site";
import Topic from "discourse/models/topic";
import { i18n } from "discourse-i18n";
export default class CategoryList extends ArrayProxy {
const STAT_PERIODS = ["week", "month"];
/**
* Represents a list of categories with their related metadata and functionality
*/
export default class CategoryList extends LegacyArrayLikeObject {
/**
* Creates category objects from API result data
*
* @param {Object} store - The store instance
* @param {Object} result - The API result containing category data
* @param {Object} parentCategory - Optional parent category
* @returns {CategoryList} A new CategoryList instance with the processed categories
*/
static categoriesFrom(store, result, parentCategory = null) {
// Find the period that is most relevant
const list = result?.category_list?.categories || [];
const statPeriod =
["week", "month"].find(
STAT_PERIODS.find(
(period) =>
result.category_list.categories.filter(
(c) => c[`topics_${period}`] > 0
).length >=
result.category_list.categories.length * 0.66
list.filter((c) => c?.[`topics_${period}`] > 0).length >=
list.length * 0.66
) || "all";
// Update global category list to make sure that `findById` works as
// expected later
result.category_list.categories.forEach((c) =>
Site.current().updateCategory(c)
);
list.forEach((c) => Site.current().updateCategory(c));
const categories = CategoryList.create({ store });
result.category_list.categories.forEach((c) => {
list.forEach((c) => {
c = this._buildCategoryResult(c, statPeriod);
if (
(parentCategory && c.parent_category_id === parentCategory.id) ||
(!parentCategory && !c.parent_category_id)
) {
categories.pushObject(c);
categories.content.push(c);
}
});
return categories;
}
static _buildCategoryResult(c, statPeriod) {
if (c.topics) {
c.topics = c.topics.map((t) => Topic.create(t));
/**
* Builds a category result object with stats and topic data
* @param {Object} rawCategoryData - The raw category data
* @param {string} statPeriod - The period to use for stats ('week', 'month', or 'all')
* @returns {Category} The processed category object
* @private
*/
static _buildCategoryResult(rawCategoryData, statPeriod) {
if (rawCategoryData.topics?.length) {
rawCategoryData.topics = rawCategoryData.topics.map((t) =>
Topic.create(t)
);
}
const stat = c[`topics_${statPeriod}`];
if ((statPeriod === "week" || statPeriod === "month") && stat > 0) {
const stat = rawCategoryData[`topics_${statPeriod}`];
const isTimedPeriod = statPeriod === "week" || statPeriod === "month";
if (isTimedPeriod && stat > 0) {
const unit = i18n(`categories.topic_stat_unit.${statPeriod}`);
c.stat = i18n("categories.topic_stat", {
rawCategoryData.stat = i18n("categories.topic_stat", {
count: stat, // only used to correctly pluralize the string
number: `<span class="value">${number(stat)}</span>`,
unit: `<span class="unit">${unit}</span>`,
});
c.statTitle = i18n(`categories.topic_stat_sentence_${statPeriod}`, {
count: stat,
});
rawCategoryData.statTitle = i18n(
`categories.topic_stat_sentence_${statPeriod}`,
{
count: stat,
}
);
c.pickAll = false;
rawCategoryData.pickAll = false;
} else {
c.stat = `<span class="value">${number(c.topics_all_time)}</span>`;
c.statTitle = i18n("categories.topic_sentence", {
count: c.topics_all_time,
rawCategoryData.stat = `<span class="value">${number(rawCategoryData.topics_all_time)}</span>`;
rawCategoryData.statTitle = i18n("categories.topic_sentence", {
count: rawCategoryData.topics_all_time,
});
c.pickAll = true;
rawCategoryData.pickAll = true;
}
if (Site.current().mobileView) {
c.statTotal = i18n("categories.topic_stat_all_time", {
count: c.topics_all_time,
number: `<span class="value">${number(c.topics_all_time)}</span>`,
rawCategoryData.statTotal = i18n("categories.topic_stat_all_time", {
count: rawCategoryData.topics_all_time,
number: `<span class="value">${number(rawCategoryData.topics_all_time)}</span>`,
});
}
const record = Site.current().updateCategory(c);
const record = Site.current().updateCategory(rawCategoryData);
record.setupGroupsAndPermissions();
return record;
}
static listForParent(store, category) {
/**
* @deprecated Use list() instead
*/
static listForParent(store, parentCategory) {
deprecated(
"The listForParent method of CategoryList is deprecated. Use list instead",
{ id: "discourse.category-list.listForParent" }
);
return CategoryList.list(store, category);
return CategoryList.list(store, parentCategory);
}
static list(store, parentCategory = null) {
return PreloadStore.getAndRemove("categories_list", () => {
const data = {};
if (parentCategory) {
data.parent_category_id = parentCategory?.id;
/**
* Fetches and creates a list of categories
*
* @param {Object} store - The store instance
* @param {Object} parentCategory - Optional parent category to filter by
* @returns {Promise<CategoryList>} A promise that resolves to the CategoryList
*/
static async list(store, parentCategory = null) {
const result = await PreloadStore.getAndRemove(
"categories_list",
async () => {
const data = {};
if (parentCategory) {
data.parent_category_id = parentCategory.id;
}
return ajax("/categories.json", { data });
}
return ajax("/categories.json", { data });
}).then((result) => {
return CategoryList.create({
store,
categories: this.categoriesFrom(store, result, parentCategory),
parentCategory,
can_create_category: result.category_list.can_create_category,
can_create_topic: result.category_list.can_create_topic,
});
);
const categoryList = result?.category_list || {};
return CategoryList.create({
store,
categories: this.categoriesFrom(store, result, parentCategory).content,
parentCategory,
can_create_category: categoryList.can_create_category,
can_create_topic: categoryList.can_create_topic,
});
}
init() {
this.set("content", this.categories || []);
super.init(...arguments);
this.set("page", 1);
this.set("fetchedLastPage", false);
/**
* Creates a new CategoryList instance
*
* @param {Object} attrs - The attributes to initialize with
* @returns {CategoryList} A new CategoryList instance
*/
static create(attrs = {}) {
const { categories, ...properties } = attrs;
return super.create({ content: categories, ...properties });
}
@tracked can_create_category;
@tracked can_create_topic;
@tracked fetchedLastPage = false;
@tracked isLoading = false;
@tracked page = 1;
@tracked parentCategory;
@trackedArray topics;
store;
/**
* @returns {Proxy} The proxied content for compatibility
* @deprecated use the category list instance instead
*/
get categories() {
deprecated(
"Using `CategoryList.categories` property is deprecated. Use `CategoryList.content` instead",
{ id: "discourse.category-list.categories" }
);
return this.content;
}
/**
* Loads more categories from the server
* @returns {Promise<void>}
*/
@bind
async loadMore() {
if (this.isLoading || this.fetchedLastPage) {
return;
}
this.set("isLoading", true);
this.isLoading = true;
const data = { page: this.page + 1 };
if (this.parentCategory) {
data.parent_category_id = this.parentCategory.id;
try {
const nextPage = this.page + 1;
const data = {
page: nextPage,
...(this.parentCategory && {
parent_category_id: this.parentCategory.id,
}),
};
const result = await ajax("/categories.json", { data });
this.page = nextPage;
const newItems = CategoryList.categoriesFrom(
this.store,
result,
this.parentCategory
).content;
if (!newItems.length) {
this.fetchedLastPage = true;
} else {
newItems.forEach((c) => this.content.push(c));
}
} finally {
this.isLoading = false;
}
const result = await ajax("/categories.json", { data });
this.set("page", data.page);
if (result.category_list.categories.length === 0) {
this.set("fetchedLastPage", true);
}
this.set("isLoading", false);
CategoryList.categoriesFrom(
this.store,
result,
this.parentCategory
).forEach((c) => this.categories.pushObject(c));
}
}
@@ -18,7 +18,7 @@ export default class DiscoveryCategoriesRoute extends DiscourseRoute {
controllerName = "discovery/categories";
async findCategories(parentCategory) {
let model;
let categoryList;
let style =
this.site.desktopView && this.siteSettings.desktop_category_page_style;
@@ -30,17 +30,20 @@ export default class DiscoveryCategoriesRoute extends DiscourseRoute {
style === "categories_and_latest_topics" ||
style === "categories_and_latest_topics_created_date"
) {
model = await this._findCategoriesAndTopics("latest", parentCategory);
categoryList = await this._findCategoriesAndTopics(
"latest",
parentCategory
);
} else if (style === "categories_and_top_topics") {
model = await this._findCategoriesAndTopics("top", parentCategory);
categoryList = await this._findCategoriesAndTopics("top", parentCategory);
} else {
// The server may have serialized this. Based on the logic above, we don't need it
// so remove it to avoid it being used later by another TopicList route.
PreloadStore.remove("topic_list");
model = await CategoryList.list(this.store, parentCategory);
categoryList = await CategoryList.list(this.store, parentCategory);
}
return model;
return categoryList;
}
async model(params) {
@@ -53,14 +56,15 @@ export default class DiscoveryCategoriesRoute extends DiscourseRoute {
: Category.findBySlugPathWithID(params.category_slug_path_with_id);
}
return this.findCategories(parentCategory).then((model) => {
const tracking = this.topicTrackingState;
if (tracking) {
tracking.sync(model, "categories");
tracking.trackIncoming("categories");
}
return model;
});
const model = await this.findCategories(parentCategory);
const tracking = this.topicTrackingState;
if (tracking) {
tracking.sync(model, "categories");
tracking.trackIncoming("categories");
}
return model;
}
_loadBefore(store) {
@@ -125,7 +129,7 @@ export default class DiscoveryCategoriesRoute extends DiscourseRoute {
this.store,
result,
parentCategory
),
).content,
parentCategory,
topics: TopicList.topicsFrom(this.store, result),
can_create_category: result.category_list.can_create_category,
@@ -54,7 +54,7 @@ export default RouteTemplate(
{{/if}}
<CategoriesDisplay
@categories={{@controller.model.categories}}
@categories={{@controller.model.content}}
@topics={{@controller.model.topics}}
@parentCategory={{@controller.model.parentCategory}}
@loadMore={{@controller.model.loadMore}}
@@ -66,7 +66,7 @@ export default RouteTemplate(
@name="below-discovery-categories"
@connectorTagName="div"
@outletArgs={{lazyHash
categories=@controller.model.categories
categories=@controller.model.content
topics=@controller.model.topics
}}
/>
@@ -37,9 +37,9 @@ export default RouteTemplate(
</:navigation>
<:header>
{{#if @controller.model.subcategoryList}}
{{#if @controller.model.subcategoryList.content}}
<CategoriesDisplay
@categories={{@controller.model.subcategoryList.categories}}
@categories={{@controller.model.subcategoryList.content}}
@parentCategory={{@controller.model.subcategoryList.parentCategory}}
@loadMore={{@controller.model.subcategoryList.loadMore}}
/>
@@ -0,0 +1,65 @@
import { render, settled } from "@ember/test-helpers";
import { setupRenderingTest } from "ember-qunit";
import { module, test } from "qunit";
import CategoryList from "discourse/models/category-list";
module("Integration | Component | CategoryList", function (hooks) {
setupRenderingTest(hooks);
test("UI updates when CategoryList array and properties change", async function (assert) {
const categories = [
{ id: 1, name: "Cat 1" },
{ id: 2, name: "Cat 2" },
];
this.categoryList = CategoryList.create({ categories });
await render(
<template>
<div data-test-category-list>
<ul>
{{#each this.categoryList.content as |cat|}}
<li data-test-category>{{cat.name}}</li>
{{/each}}
</ul>
<div data-test-page>Page: {{this.categoryList.page}}</div>
<div data-test-fetched-last-page>Fetched Last Page:
{{this.categoryList.fetchedLastPage}}</div>
</div>
</template>
);
assert
.dom("[data-test-category]")
.exists({ count: 2 }, "renders initial categories");
assert.dom("[data-test-category]:nth-child(1)").hasText("Cat 1");
assert.dom("[data-test-category]:nth-child(2)").hasText("Cat 2");
// Add a category (mutate .content, not the proxy)
this.categoryList.content.push({ id: 3, name: "Cat 3" });
await settled();
assert
.dom("[data-test-category]")
.exists({ count: 3 }, "renders after adding category");
assert.dom("[data-test-category]:nth-child(3)").hasText("Cat 3");
// Remove a category
this.categoryList.content.splice(0, 1);
await settled();
assert
.dom("[data-test-category]")
.exists({ count: 2 }, "renders after removing category");
assert.dom("[data-test-category]:nth-child(1)").hasText("Cat 2");
// Change page property
this.categoryList.page = 2;
await settled();
assert.dom("[data-test-page]").hasText("Page: 2");
// Change fetchedLastPage property
this.categoryList.fetchedLastPage = true;
await settled();
assert
.dom("[data-test-fetched-last-page]")
.hasText("Fetched Last Page: true");
});
});
@@ -0,0 +1,541 @@
import { getOwner } from "@ember/owner";
import { TrackedArray } from "@ember-compat/tracked-built-ins";
import { setupTest } from "ember-qunit";
import { module, test } from "qunit";
import { withSilencedDeprecations } from "discourse/lib/deprecated";
import LegacyArrayLikeObject from "discourse/lib/legacy-array-like-object";
import { withPluginApi } from "discourse/lib/plugin-api";
module("Unit | lib | LegacyArrayLikeObject", function (hooks) {
setupTest(hooks);
test("constructs with default values", function (assert) {
withSilencedDeprecations(
"discourse.legacy-array-like-object.proxied-array",
() => {
const obj = LegacyArrayLikeObject.create();
assert.true(
obj instanceof LegacyArrayLikeObject,
"returns an LegacyArrayLikeObject instance"
);
assert.strictEqual(obj.length, 0, "empty by default");
}
);
});
test("accepts initial items", function (assert) {
withSilencedDeprecations(
"discourse.legacy-array-like-object.proxied-array",
() => {
const obj = LegacyArrayLikeObject.create({ content: [1, 2, 3] });
assert.deepEqual([...obj], [1, 2, 3], "contains initial items");
assert.strictEqual(obj.length, 3, "length is correct");
}
);
});
test("accepts TrackedArray as items", function (assert) {
withSilencedDeprecations(
"discourse.legacy-array-like-object.proxied-array",
() => {
const arr = new TrackedArray([4, 5]);
const obj = LegacyArrayLikeObject.create({ content: arr });
assert.strictEqual(obj[0], 4);
assert.strictEqual(obj[1], 5);
assert.strictEqual(obj.length, 2);
}
);
});
test("assigns custom properties", function (assert) {
withSilencedDeprecations(
"discourse.legacy-array-like-object.proxied-array",
() => {
const obj = LegacyArrayLikeObject.create({ content: [1], foo: "bar" });
assert.strictEqual(obj.foo, "bar", "property is assigned");
obj.foo = "baz";
assert.strictEqual(obj.foo, "baz", "property is settable");
}
);
});
test("throws if content is not an array", function (assert) {
withSilencedDeprecations(
"discourse.legacy-array-like-object.proxied-array",
() => {
assert.throws(
() => LegacyArrayLikeObject.create({ content: "not an array" }),
/must be an array/
);
}
);
});
test("array methods work", function (assert) {
withSilencedDeprecations(
"discourse.legacy-array-like-object.proxied-array",
() => {
let obj = LegacyArrayLikeObject.create({ content: [1, 2] });
obj.push(3);
assert.deepEqual([...obj], [1, 2, 3]);
assert.strictEqual(obj.pop(), 3);
assert.deepEqual([...obj], [1, 2]);
obj = LegacyArrayLikeObject.create({ content: [1, 2, 3, 4] });
assert.deepEqual(
obj.map((x) => x * 2),
[2, 4, 6, 8],
"map works"
);
assert.deepEqual(
obj.filter((x) => x % 2 === 0),
[2, 4],
"filter works"
);
assert.strictEqual(
obj.find((x) => x > 2),
3,
"find works"
);
assert.strictEqual(
obj.findIndex((x) => x === 3),
2,
"findIndex works"
);
assert.true(
obj.some((x) => x === 2),
"some works"
);
assert.true(
obj.every((x) => x > 0),
"every works"
);
assert.false(
obj.every((x) => x > 2),
"every works for false"
);
assert.strictEqual(
obj.reduce((a, b) => a + b, 0),
10,
"reduce works"
);
assert.deepEqual(obj.slice(1, 3), [2, 3], "slice works");
assert.deepEqual(
obj.concat([5, 6]),
[1, 2, 3, 4, 5, 6],
"concat works"
);
assert.deepEqual(obj.slice().reverse(), [4, 3, 2, 1], "reverse works");
assert.true(obj.includes(3), "includes works");
assert.false(obj.includes(99), "includes works for false");
assert.strictEqual(obj.at(0), 1, "at(0) works");
assert.strictEqual(obj.at(-1), 4, "at(-1) works");
}
);
});
test("instance properties take precedence over array", function (assert) {
withSilencedDeprecations(
"discourse.legacy-array-like-object.proxied-array",
() => {
class CustomArrayLike extends LegacyArrayLikeObject {
get first() {
return "custom";
}
}
const obj = CustomArrayLike.create({ content: [1, 2] });
assert.strictEqual(obj.first, "custom");
}
);
});
test("subclassing works", function (assert) {
withSilencedDeprecations(
"discourse.legacy-array-like-object.proxied-array",
() => {
class CustomArrayLike extends LegacyArrayLikeObject {
customField = "foo";
#bar = 42;
get bar() {
return this.#bar;
}
set bar(val) {
this.#bar = val;
}
get firstPlusBar() {
return (this[0] || 0) + this.bar;
}
customMethod() {
return this.length * 10;
}
}
const obj = CustomArrayLike.create({ content: [5, 6, 7] });
assert.strictEqual(obj.customField, "foo", "custom field is present");
assert.strictEqual(obj.bar, 42, "getter works");
obj.bar = 100;
assert.strictEqual(obj.bar, 100, "setter works");
assert.strictEqual(obj.customMethod(), 30, "custom method works");
assert.strictEqual(
obj.firstPlusBar,
105,
"getter using array and field works"
);
assert.strictEqual(obj.length, 3, "length is correct");
assert.strictEqual(obj[0], 5, "index access works");
obj.push(8);
assert.deepEqual([...obj], [5, 6, 7, 8], "push works");
assert.strictEqual(obj.pop(), 8, "pop works");
assert.deepEqual(
obj.map((x) => x * 2),
[10, 12, 14],
"map works"
);
}
);
});
test("multiple levels of inheritance work as expected", function (assert) {
withSilencedDeprecations(
"discourse.legacy-array-like-object.proxied-array",
() => {
class BaseArrayLike extends LegacyArrayLikeObject {
baseField = "base";
get baseValue() {
return this.baseField + this.length;
}
}
class MidArrayLike extends BaseArrayLike {
midField = "mid";
get midValue() {
return this.midField + (this[0] || 0);
}
}
class FinalArrayLike extends MidArrayLike {
finalField = "final";
get finalValue() {
return this.finalField + (this[1] || 0);
}
customMethod() {
return this.baseValue + this.midValue + this.finalValue;
}
}
const obj = FinalArrayLike.create({ content: [10, 20] });
assert.strictEqual(obj.baseField, "base", "base field present");
assert.strictEqual(obj.baseValue, "base2", "base getter works");
assert.strictEqual(obj.midField, "mid", "mid field present");
assert.strictEqual(obj.midValue, "mid10", "mid getter works");
assert.strictEqual(obj.finalField, "final", "final field present");
assert.strictEqual(obj.finalValue, "final20", "final getter works");
assert.strictEqual(
obj.customMethod(),
"base2mid10final20",
"custom method combines all levels"
);
assert.strictEqual(obj.length, 2, "length is correct");
assert.strictEqual(obj[0], 10, "index access works");
obj.push(30);
assert.deepEqual([...obj], [10, 20, 30], "push works");
assert.strictEqual(obj.pop(), 30, "pop works");
assert.deepEqual(
obj.map((x) => x + 1),
[11, 21],
"map works"
);
}
);
});
test("pluginApi.modifyClass works", function (assert) {
class BaseArrayLike extends LegacyArrayLikeObject {
baseField = "base";
get baseValue() {
return this.baseField + this.content.length;
}
}
class MidArrayLike extends BaseArrayLike {
midField = "mid";
get midValue() {
return this.midField + (this.content[0] || 0);
}
}
class FinalArrayLike extends MidArrayLike {
finalField = "final";
_value = "initial";
get valueGetter() {
return "original";
}
get finalValue() {
return this.finalField + (this.content[1] || 0);
}
customMethod() {
return this.baseValue + this.midValue + this.finalValue;
}
}
getOwner(this).register("final-array-like:main", FinalArrayLike);
// Plugin API modifies the class
withPluginApi((api) => {
api.modifyClass(
"final-array-like:main",
(Superclass) =>
class extends Superclass {
// overriding getter from a base class works
get valueGetter() {
return super.valueGetter + " was modified";
}
get pluginValue() {
return (this.content[2] || 0) + this._value;
}
set pluginValue(val) {
this._value = val;
}
get customSetterValue() {
return this._customSetterValue;
}
set customSetterValue(val) {
this._customSetterValue = val * 2;
}
customMethod() {
return (
"plugin-" + this.baseValue + this.midValue + this.finalValue
);
}
}
);
});
const obj = getOwner(this).lookup("final-array-like:main");
obj.content.push(10, 20, 30);
// The original inheritance chain still works
assert.strictEqual(obj.baseField, "base", "base field present");
assert.strictEqual(obj.baseValue, "base3", "base getter works");
assert.strictEqual(obj.midField, "mid", "mid field present");
assert.strictEqual(obj.midValue, "mid10", "mid getter works");
assert.strictEqual(obj.finalField, "final", "final field present");
assert.strictEqual(obj.finalValue, "final20", "final getter works");
// Plugin modifications
assert.strictEqual(obj.pluginValue, "30initial", "plugin getter works");
obj.pluginValue = "changed";
assert.strictEqual(obj.pluginValue, "30changed", "plugin setter works");
assert.strictEqual(
obj._value,
"changed",
"plugin setter sets backing field"
);
obj.customSetterValue = 5;
assert.strictEqual(
obj.customSetterValue,
10,
"customSetterValue setter works"
);
assert.strictEqual(
obj._customSetterValue,
10,
"customSetterValue setter sets backing field"
);
assert.strictEqual(
obj.customSetterValue,
10,
"customSetterValue getter works"
);
assert.strictEqual(
obj.valueGetter,
"original was modified",
"plugin getter overrides original"
);
assert.strictEqual(
obj.customMethod(),
"plugin-base3mid10final20",
"plugin method overrides original"
);
// Array-like behavior
assert.strictEqual(obj.content.length, 3, "length is correct");
assert.strictEqual(obj.content[0], 10, "index access works");
obj.content.push(40);
assert.deepEqual([...obj.content], [10, 20, 30, 40], "push works");
assert.strictEqual(obj.content.pop(), 40, "pop works");
assert.deepEqual(
obj.content.map((x) => x + 1),
[11, 21, 31],
"map works"
);
});
test("Array.isArray checks", function (assert) {
withSilencedDeprecations(
"discourse.legacy-array-like-object.proxied-array",
() => {
const obj = LegacyArrayLikeObject.create([1, 2, 3]);
assert.true(
Array.isArray(obj),
"LegacyArrayLikeObject is considered an array"
);
assert.true(Array.isArray([...obj]), "spread result is a true array");
}
);
});
test("spread, for..of, for..in iteration", function (assert) {
withSilencedDeprecations(
"discourse.legacy-array-like-object.proxied-array",
() => {
const obj = LegacyArrayLikeObject.create([10, 20, 30]);
assert.deepEqual([...obj], [10, 20, 30], "array spread works");
const values = [];
for (const v of obj) {
values.push(v);
}
assert.deepEqual(values, [10, 20, 30], "for..of works");
const keys = [];
for (const k in obj) {
if (!isNaN(Number(k))) {
keys.push(Number(k));
}
}
assert.deepEqual(keys, [0, 1, 2], "for..in yields array indices");
obj.foo = "bar";
const props = [];
for (const k in obj) {
if (obj.hasOwnProperty(k)) {
props.push(k);
}
}
assert.true(props.includes("foo"), "for..in yields custom properties");
class SubArrayLike extends LegacyArrayLikeObject {
custom = true;
}
const sub = SubArrayLike.create({ content: [1, 2] });
const subKeys = [];
for (const k in sub) {
if (!sub.hasOwnProperty(k)) {
continue;
}
subKeys.push(k);
}
assert.true(
subKeys.includes("custom"),
"for..in yields subclass properties"
);
const empty = LegacyArrayLikeObject.create();
assert.deepEqual([...empty], [], "spread works for empty");
const emptyVals = [];
for (const v of empty) {
emptyVals.push(v);
}
assert.deepEqual(emptyVals, [], "for..of works for empty");
}
);
});
test("object spread operator", function (assert) {
withSilencedDeprecations(
"discourse.legacy-array-like-object.proxied-array",
() => {
const obj = LegacyArrayLikeObject.create([1, 2, 3]);
let spread = { ...obj };
assert.deepEqual(
Object.keys(spread),
["0", "1", "2"],
"array indices are enumerable own properties by default"
);
assert.deepEqual(
spread,
{ 0: 1, 1: 2, 2: 3 },
"spread result contains array elements by default"
);
obj.foo = "bar";
spread = { ...obj };
assert.true(spread.hasOwnProperty("foo"), "custom property is present");
assert.strictEqual(
spread.foo,
"bar",
"custom property value is correct"
);
assert.strictEqual(spread[0], 1, "array element still present");
obj[0] = 99;
spread = { ...obj };
assert.true(
spread.hasOwnProperty("0"),
"numeric property is present if own"
);
assert.strictEqual(
spread[0],
99,
"numeric property value overrides array element"
);
class SubArrayLike extends LegacyArrayLikeObject {
custom = 42;
}
const sub = SubArrayLike.create({ content: [5, 6] });
let subSpread = { ...sub };
assert.true(
subSpread.hasOwnProperty("custom"),
"subclass own property is present"
);
assert.strictEqual(
subSpread.custom,
42,
"subclass property value is correct"
);
assert.strictEqual(subSpread[0], 5, "subclass array element present");
const empty = LegacyArrayLikeObject.create();
const emptySpread = { ...empty };
assert.deepEqual(emptySpread, {}, "spread of empty object is empty");
}
);
});
test("content property returns underlying array", function (assert) {
withSilencedDeprecations(
"discourse.legacy-array-like-object.proxied-array",
() => {
const arr = [1, 2, 3];
const obj = LegacyArrayLikeObject.create(arr);
assert.deepEqual(
obj.content,
arr,
"content property matches input array"
);
obj.push(4);
assert.deepEqual(
obj.content,
[1, 2, 3, 4],
"content property updates after mutation"
);
}
);
});
test("constructor is private and cannot be called directly", function (assert) {
assert.throws(() => {
// eslint-disable-next-line no-new
new LegacyArrayLikeObject([1, 2, 3]);
}, /private constructor|is not a constructor|TypeError/);
});
});
@@ -0,0 +1,489 @@
import { getOwner } from "@ember/owner";
import { setupTest } from "ember-qunit";
import { module, test } from "qunit";
import { withSilencedDeprecations } from "discourse/lib/deprecated";
import PreloadStore from "discourse/lib/preload-store";
import CategoryList from "discourse/models/category-list";
import Site from "discourse/models/site";
import Topic from "discourse/models/topic";
import pretender, { response } from "discourse/tests/helpers/create-pretender";
import { i18n } from "discourse-i18n";
module("Unit | Model | CategoryList", function (hooks) {
setupTest(hooks);
hooks.beforeEach(function () {
this.store = getOwner(this).lookup("service:store");
});
test("categoriesFrom creates categories from API result", function (assert) {
const result = {
category_list: {
categories: [
{
id: 1,
name: "General",
topics_week: 5,
topics_month: 20,
topics_all_time: 100,
parent_category_id: null,
},
{
id: 2,
name: "Support",
topics_week: 0,
topics_month: 3,
topics_all_time: 50,
parent_category_id: null,
},
],
},
};
const categoryList = CategoryList.categoriesFrom(this.store, result);
assert.true(categoryList instanceof CategoryList);
assert.strictEqual(
categoryList.content.length,
2,
".content provides clean access to the array"
);
withSilencedDeprecations(
"discourse.legacy-array-like-object.proxied-array",
() => {
assert.strictEqual(categoryList.length, 2, "proxy length works");
}
);
});
test("categoriesFrom filters categories by parent category", function (assert) {
const parentCategory = { id: 1 };
const result = {
category_list: {
categories: [
{
id: 2,
name: "Child Category",
parent_category_id: 1,
topics_all_time: 10,
},
{
id: 3,
name: "Other Category",
parent_category_id: 2,
topics_all_time: 15,
},
],
},
};
const categoryList = CategoryList.categoriesFrom(
this.store,
result,
parentCategory
);
assert.strictEqual(categoryList.content.length, 1);
withSilencedDeprecations(
"discourse.legacy-array-like-object.proxied-array",
() => {
assert.strictEqual(categoryList.length, 1, "proxy length works");
}
);
});
test("categoriesFrom handles empty category list", function (assert) {
const result = { category_list: { categories: [] } };
const categoryList = CategoryList.categoriesFrom(this.store, result);
assert.true(categoryList instanceof CategoryList);
assert.strictEqual(categoryList.content.length, 0);
withSilencedDeprecations(
"discourse.legacy-array-like-object.proxied-array",
() => {
assert.strictEqual(categoryList.length, 0, "proxy length works");
}
);
});
test("array methods on .content work and do not warn", function (assert) {
const categoryList = CategoryList.create({ categories: [] });
categoryList.content.push({ id: 1 });
assert.strictEqual(categoryList.content.length, 1);
categoryList.content.splice(0, 1);
assert.strictEqual(categoryList.content.length, 0);
});
test("_buildCategoryResult builds category with week stats", function (assert) {
const rawData = {
id: 1,
topics_week: 10,
topics_month: 20,
topics_all_time: 100,
};
const result = CategoryList._buildCategoryResult(rawData, "week");
assert.true(result.stat.includes("10"));
assert.true(result.stat.includes("value"));
assert.strictEqual(
result.statTitle,
i18n(`categories.topic_stat_sentence_week`, {
count: rawData.topics_week,
})
);
assert.false(result.pickAll);
});
test("_buildCategoryResult builds category with all-time stats when no recent activity", function (assert) {
const rawData = {
id: 1,
topics_week: 0,
topics_month: 0,
topics_all_time: 50,
};
const result = CategoryList._buildCategoryResult(rawData, "week");
assert.true(result.stat.includes("50"));
assert.true(result.pickAll);
});
test("_buildCategoryResult processes topics array", function (assert) {
const rawData = {
id: 1,
topics: [{ id: 1, title: "Test Topic" }],
topics_all_time: 10,
};
const result = CategoryList._buildCategoryResult(rawData, "all");
assert.strictEqual(result.topics.length, 1);
assert.true(result.topics[0] instanceof Topic);
});
test("_buildCategoryResult adds mobile stats", function (assert) {
const originalSiteCurrent = Site.current;
Site.current = () => ({
updateCategory: (category) => {
category.setupGroupsAndPermissions = () => {};
return category;
},
mobileView: true,
});
const rawData = {
id: 1,
topics_all_time: 100,
};
const result = CategoryList._buildCategoryResult(rawData, "all");
assert.true(result.statTotal.includes("100"));
Site.current = originalSiteCurrent;
});
test("list fetches and clears categories from PreloadStore", async function (assert) {
const mockResult = {
category_list: {
categories: [{ id: 1, name: "Test", topics_all_time: 10 }],
can_create_category: true,
can_create_topic: true,
},
};
// Store the mock result in PreloadStore under the correct key
PreloadStore.store("categories_list", mockResult);
// Spy on AJAX to ensure it is NOT called
let ajaxCalled = false;
pretender.get("/categories.json", () => {
ajaxCalled = true;
return response({});
});
const categoryList = await CategoryList.list(this.store);
assert.true(categoryList instanceof CategoryList, "Returns a CategoryList");
assert.true(categoryList.can_create_category, "can_create_category is set");
assert.true(categoryList.can_create_topic, "can_create_topic is set");
assert.false(
ajaxCalled,
"AJAX should not be called if PreloadStore is used"
);
assert.strictEqual(
PreloadStore.get("categories_list"),
undefined,
"PreloadStore key is cleared after use"
);
});
test("list includes parent category ID in request", async function (assert) {
const parentCategory = { id: 5 };
let requestData = {};
pretender.get("/categories.json", (request) => {
requestData = request.queryParams;
return response({
category_list: { categories: [] },
});
});
await CategoryList.list(this.store, parentCategory);
assert.strictEqual(parseInt(requestData.parent_category_id, 10), 5);
});
test(".create creates new CategoryList instance", function (assert) {
const attrs = {
categories: [],
can_create_category: true,
};
const categoryList = CategoryList.create(attrs);
assert.true(categoryList instanceof CategoryList);
});
test("loadMore loads more categories successfully", async function (assert) {
let requestData = {};
pretender.get("/categories.json", (request) => {
requestData = request.queryParams;
return response({
category_list: {
categories: [{ id: 2, name: "New Category", topics_all_time: 5 }],
},
});
});
const categoryList = CategoryList.create({
categories: [],
store: this.store,
page: 1,
});
await categoryList.loadMore();
assert.strictEqual(categoryList.page, 2);
assert.false(categoryList.isLoading);
assert.strictEqual(parseInt(requestData.page, 10), 2);
});
test("loadMore sets fetchedLastPage when no more categories", async function (assert) {
pretender.get("/categories.json", () => {
return response({
category_list: { categories: [] },
});
});
const categoryList = CategoryList.create({
categories: [],
store: this.store,
});
await categoryList.loadMore();
assert.true(categoryList.fetchedLastPage);
});
test("loadMore includes parent category ID in request", async function (assert) {
const parentCategory = { id: 3 };
let requestData = {};
pretender.get("/categories.json", (request) => {
requestData = request.queryParams;
return response({
category_list: { categories: [] },
});
});
const categoryList = CategoryList.create({
categories: [],
store: this.store,
parentCategory,
});
await categoryList.loadMore();
assert.strictEqual(parseInt(requestData.page, 10), 2);
assert.strictEqual(parseInt(requestData.parent_category_id, 10), 3);
});
test("loadMore does not load when already loading", async function (assert) {
let ajaxCalled = false;
pretender.get("/categories.json", () => {
ajaxCalled = true;
return response({});
});
const categoryList = CategoryList.create({
categories: [],
store: this.store,
isLoading: true,
});
await categoryList.loadMore();
assert.false(ajaxCalled, "Ajax should not be called");
});
test("loadMore does not load when last page is fetched", async function (assert) {
let ajaxCalled = false;
pretender.get("/categories.json", () => {
ajaxCalled = true;
return response({});
});
const categoryList = CategoryList.create({
categories: [],
store: this.store,
fetchedLastPage: true,
});
await categoryList.loadMore();
assert.false(ajaxCalled, "Ajax should not be called");
});
test("loadMore handles ajax error gracefully", async function (assert) {
pretender.get("/categories.json", () => {
return response(500, { errors: ["Network error"] });
});
const categoryList = CategoryList.create({
categories: [],
store: this.store,
});
try {
await categoryList.loadMore();
} catch {
// Error should be thrown but loading state should be reset
}
assert.false(categoryList.isLoading);
});
test("CategoryList behaves like an array", function (assert) {
withSilencedDeprecations(
"discourse.legacy-array-like-object.proxied-array",
() => {
const categories = [
{ id: 1, name: "Cat 1", topics_all_time: 10 },
{ id: 2, name: "Cat 2", topics_all_time: 20 },
{ id: 3, name: "Cat 3", topics_all_time: 30 },
];
const list = CategoryList.create({ categories });
// list[0] returns the first element
assert.strictEqual(list[0].id, 1, "list[0] returns first category");
// list.length returns correct length
assert.strictEqual(
list.length,
3,
"list.length returns correct length"
);
// forEach works
let ids = [];
list.forEach((cat) => ids.push(cat.id));
assert.deepEqual(
ids,
[1, 2, 3],
"forEach iterates over all categories"
);
// map works
const names = list.map((cat) => cat.name);
assert.deepEqual(
names,
["Cat 1", "Cat 2", "Cat 3"],
"map returns names"
);
// filter works
const filtered = list.filter((cat) => cat.topics_all_time > 10);
assert.strictEqual(filtered.length, 2, "filter returns correct number");
assert.strictEqual(
filtered[0].id,
2,
"filter returns correct category"
);
// find works
const found = list.find((cat) => cat.id === 2);
assert.strictEqual(
found.name,
"Cat 2",
"find returns correct category"
);
// findIndex works
const foundIdx = list.findIndex((cat) => cat.id === 3);
assert.strictEqual(foundIdx, 2, "findIndex returns correct index");
// some works
assert.true(
list.some((cat) => cat.topics_all_time === 20),
"some returns true if any match"
);
// every works
assert.true(
list.every((cat) => cat.id > 0),
"every returns true if all match"
);
assert.false(
list.every((cat) => cat.topics_all_time > 10),
"every returns false if not all match"
);
// reduce works
const totalTopics = list.reduce(
(sum, cat) => sum + cat.topics_all_time,
0
);
assert.strictEqual(totalTopics, 60, "reduce sums topics_all_time");
// slice works
const sliced = list.slice(1);
assert.strictEqual(sliced.length, 2, "slice returns correct length");
assert.strictEqual(sliced[0].id, 2, "slice returns correct element");
// concat works
const extra = { id: 4, name: "Cat 4", topics_all_time: 40 };
const combined = list.concat([extra]);
assert.strictEqual(combined.length, 4, "concat returns correct length");
assert.strictEqual(combined[3].id, 4, "concat returns correct element");
// reverse works
const reversed = list.slice().reverse();
assert.deepEqual(
reversed.map((cat) => cat.id),
[3, 2, 1],
"reverse returns reversed array"
);
// includes works
assert.true(
list.includes(list[1]),
"includes returns true for contained element"
);
assert.false(
list.includes({ id: 99 }),
"includes returns false for non-contained element"
);
// at works
assert.strictEqual(list.at(0).id, 1, "at(0) returns first element");
assert.strictEqual(list.at(-1).id, 3, "at(-1) returns last element");
}
);
});
});