pull/3/head
ZYY 3 months ago
parent cb079b0acd
commit f082caf237

@ -1,39 +1,64 @@
/**
* Admin Tags Page
*
* Responsibilities:
* - Fetch tags using useBrowseTags and the current visibility filter (public/internal)
* - Render loading, error, empty and list states
* - Wire list pagination (infinite) into TagsList
*
*
* `useBrowseTags` tags API
* URL `type` public/internal
* /// `TagsList`
*/
import React from 'react';
import TagsContent from './components/TagsContent';
import TagsHeader from './components/TagsHeader';
import TagsLayout from './components/TagsLayout';
import TagsList from './components/TagsList';
import {Button, LoadingIndicator, LucideIcon} from '@tryghost/shade';
import {useBrowseTags} from '@tryghost/admin-x-framework/api/tags';
import {useLocation} from '@tryghost/admin-x-framework';
import { Button, LoadingIndicator, LucideIcon } from '@tryghost/shade';
import { useBrowseTags } from '@tryghost/admin-x-framework/api/tags';
import { useLocation } from '@tryghost/admin-x-framework';
/**
*
*
*/
const Tags: React.FC = () => {
const {search} = useLocation();
// 获取当前URL的查询参数用于确定标签筛选类型
const { search } = useLocation();
const qs = new URLSearchParams(search);
// 从查询参数中获取标签类型(默认为'public'公开标签)
const type = qs.get('type') ?? 'public';
// 调用标签浏览API根据可见性类型筛选标签
const {
data,
isError,
isLoading,
isFetchingNextPage,
fetchNextPage,
hasNextPage
data, // 接口返回的标签数据
isError, // 接口请求是否出错
isLoading, // 接口是否正在加载(首次)
isFetchingNextPage, // 是否正在加载下一页数据
fetchNextPage, // 加载下一页数据的函数
hasNextPage // 是否有下一页数据
} = useBrowseTags({
filter: {
visibility: type
visibility: type // 筛选条件:按可见性类型
}
});
return (
<TagsLayout>
{/* 标签页面头部,显示当前选中的标签类型标签 */}
<TagsHeader currentTab={type} />
{/* 标签内容区域 */}
<TagsContent>
{/* 加载状态:显示加载指示器 */}
{isLoading ? (
<div className="flex h-full items-center justify-center">
<LoadingIndicator size="lg" />
</div>
) : isError ? (
{/* 错误状态:显示错误信息和重试按钮 */}
<div className="mb-16 flex h-full flex-col items-center justify-center">
<h2 className="mb-2 text-xl font-medium">
Error loading tags
@ -46,6 +71,7 @@ const Tags: React.FC = () => {
</Button>
</div>
) : !data?.tags.length ? (
{/* 空状态:当没有标签时显示引导创建标签 */}
<div className="mb-16 flex h-full flex-col items-center justify-center gap-8">
<LucideIcon.Tags className="-mb-4 size-16 text-muted-foreground" strokeWidth={1} />
<h2 className="text-xl font-medium">
@ -56,12 +82,13 @@ const Tags: React.FC = () => {
</Button>
</div>
) : (
{/* 列表状态:显示标签列表,支持分页加载 */}
<TagsList
fetchNextPage={fetchNextPage}
hasNextPage={hasNextPage}
isFetchingNextPage={isFetchingNextPage}
items={data?.tags ?? []}
totalItems={data?.meta?.pagination?.total ?? 0}
fetchNextPage={fetchNextPage} // 加载下一页的回调
hasNextPage={hasNextPage} // 是否有更多数据
isFetchingNextPage={isFetchingNextPage} // 是否正在加载下一页
items={data?.tags ?? []} // 标签数据列表
totalItems={data?.meta?.pagination?.total ?? 0} // 总标签数量
/>
)}
</TagsContent>
@ -69,4 +96,4 @@ const Tags: React.FC = () => {
);
};
export default Tags;
export default Tags;

@ -1,3 +1,5 @@
// TagsHeader: top navigation for the tags admin page
// - shows public/internal tab selection and New tag action
import React from 'react';
import {Button, Header, PageMenu, PageMenuItem} from '@tryghost/shade';
import {Link} from '@tryghost/admin-x-framework';

@ -1,3 +1,11 @@
/**
* TagsList component
*
* Responsibilities:
* - Render a virtualized/infinite-scrolling table of tags
+ * - Show placeholders while items are being fetched
+ * - Render each tag row with name, slug, post count and edit action
+ */
import {
Button,
LucideIcon,
@ -9,16 +17,26 @@ import {
TableRow,
formatNumber
} from '@tryghost/shade';
import {Tag} from '@tryghost/admin-x-framework/api/tags';
import {forwardRef, useRef} from 'react';
import {useInfiniteVirtualScroll} from './VirtualTable/useInfiniteVirtualScroll';
import { Tag } from '@tryghost/admin-x-framework/api/tags';
import { forwardRef, useRef } from 'react';
import { useInfiniteVirtualScroll } from './VirtualTable/useInfiniteVirtualScroll';
const SpacerRow = ({height}: { height: number }) => (
/**
*
* @param {Object} props -
* @param {number} props.height -
* @returns {JSX.Element}
*/
const SpacerRow = ({ height }: { height: number }) => (
<tr className="flex lg:table-row">
<td className="flex lg:table-cell" style={{height}} />
<td className="flex lg:table-cell" style={{ height }} />
</tr>
);
/**
*
* TODO: React 19forwardRef
*/
// TODO: Remove forwardRef once we have upgraded to React 19
const PlaceholderRow = forwardRef<HTMLTableRowElement>(function PlaceholderRow(
props,
@ -37,6 +55,16 @@ const PlaceholderRow = forwardRef<HTMLTableRowElement>(function PlaceholderRow(
);
});
/**
*
* @param {Object} props -
* @param {Tag[]} props.items -
* @param {number} props.totalItems -
* @param {boolean} [props.hasNextPage] -
* @param {boolean} [props.isFetchingNextPage] -
* @param {() => void} props.fetchNextPage -
* @returns {JSX.Element}
*/
function TagsList({
items,
totalItems,
@ -50,8 +78,11 @@ function TagsList({
isFetchingNextPage?: boolean;
fetchNextPage: () => void;
}) {
// 父容器引用,用于计算虚拟滚动区域
const parentRef = useRef<HTMLDivElement>(null);
const {visibleItems, spaceBefore, spaceAfter} = useInfiniteVirtualScroll({
// 调用虚拟滚动钩子,获取可见项和间隔高度
const { visibleItems, spaceBefore, spaceAfter } = useInfiniteVirtualScroll({
items,
totalItems,
hasNextPage,
@ -63,6 +94,7 @@ function TagsList({
return (
<div ref={parentRef}>
<Table className="flex table-fixed flex-col lg:table">
{/* 桌面端表头 */}
<TableHeader className="hidden lg:!visible lg:!table-header-group">
<TableRow>
<TableHead className="w-auto px-4">
@ -75,9 +107,15 @@ function TagsList({
<TableHead className="w-20 px-4"></TableHead>
</TableRow>
</TableHeader>
{/* 表格内容区域 */}
<TableBody className="flex flex-col lg:table-row-group">
{/* 顶部空白间隔(用于虚拟滚动定位) */}
<SpacerRow height={spaceBefore} />
{visibleItems.map(({key, virtualItem, item, props}) => {
{/* 渲染可见的标签行 */}
{visibleItems.map(({ key, virtualItem, item, props }) => {
// 判断是否需要渲染占位行(数据尚未加载时)
const shouldRenderPlaceholder =
virtualItem.index > items.length - 1;
@ -85,12 +123,14 @@ function TagsList({
return <PlaceholderRow key={key} {...props} />;
}
// 渲染实际标签行(响应式布局)
return (
<TableRow
key={key}
{...props}
className="relative grid w-full grid-cols-[1fr_5rem] items-center gap-x-4 p-2 md:grid-cols-[1fr_auto_5rem] lg:table-row lg:p-0"
>
{/* 标签名称和描述(移动端占满一行,桌面端占多列) */}
<TableCell className="static col-start-1 col-end-1 row-start-1 row-end-1 flex min-w-0 flex-col p-0 lg:table-cell lg:w-1/2 lg:p-4 xl:w-3/5">
<a
className="block truncate pb-1 text-lg font-medium before:absolute before:inset-0 before:z-10"
@ -102,11 +142,15 @@ function TagsList({
{item.description}
</span>
</TableCell>
{/* 标签Slug移动端第二行 */}
<TableCell className="col-start-1 col-end-1 row-start-2 row-end-2 flex p-0 lg:table-cell lg:p-4">
<span className="block truncate">
{item.slug}
</span>
</TableCell>
{/* 关联文章数量(移动端第三行,桌面端单独列) */}
<TableCell className="col-start-1 col-end-1 row-start-3 row-end-3 flex p-0 md:col-start-2 md:col-end-2 md:row-start-1 md:row-end-3 lg:table-cell lg:p-4">
{item.count?.posts ? (
<a
@ -121,6 +165,8 @@ function TagsList({
</span>
)}
</TableCell>
{/* 编辑按钮(移动端右上角,桌面端最后一列) */}
<TableCell className="col-start-2 col-end-2 row-start-1 row-end-3 p-0 md:col-start-3 md:col-end-3 lg:table-cell lg:p-4">
<Button
aria-hidden="true"
@ -135,6 +181,8 @@ function TagsList({
</TableRow>
);
})}
{/* 底部空白间隔(用于虚拟滚动定位) */}
<SpacerRow height={spaceAfter} />
</TableBody>
</Table>
@ -142,4 +190,4 @@ function TagsList({
);
}
export default TagsList;
export default TagsList;

@ -1,3 +1,11 @@
/*
* 全局应用上下文AppContext
*
* 中文说明
* - sodo-search 提供共享状态包括已加载的 posts/authors/tags 索引搜索关键字
* 以及 dispatch 方法用于触发 UI 状态改变比如打开/关闭弹窗
* - tags 列表会在 SearchIndex 初始化时被填充并用于弹窗内的 tag 搜索结果展示
*/
// Ref: https://reactjs.org/docs/context.html
import React from 'react';

@ -1,3 +1,12 @@
/*
* 搜索弹窗PopupModal组件
*
* 中文说明
* - 该文件实现站内搜索弹窗 UI用于搜索 poststagsauthors 等内容并展示结果
* - 当用户输入关键字时会使用内部的 SearchIndex `apps/sodo-search/src/search-index.js` 提供
* 来检索本地索引在初始化时会向 Ghost 内容 API 拉取 posts/authors/tags 并建立索引
* - 在结果中tags 的条目会被渲染为可点击项`TagListItem`点击会跳转到 tag.url
*/
import Frame from './Frame';
import AppContext from '../AppContext';
import {ReactComponent as SearchIcon} from '../icons/search.svg';

@ -1,3 +1,12 @@
/*
* 搜索索引SearchIndex
*
* 中文说明
* - 使用 FlexSearch 在浏览器端建立 postsauthorstags 的索引以便快速检索
* - 在初始化时会向 Ghost 内容 API 拉取 posts/authors/tags使用 admin/content search-index endpoints
* 并把返回的数据加入对应的索引文档postsIndex/authorsIndex/tagsIndex
* - tags 的索引托管在 `tagsIndex`其文档字段只索引 `name`并且使用自定义编码器以支持 CJK 分词
*/
import Flexsearch, {Charset} from 'flexsearch';
const cjkEncoderPresetCodepoint = {

@ -1,12 +1,18 @@
/* eslint-env node */
/**
* 浏览器兼容性配置
* 用于指定项目需要支持的浏览器版本范围
* 通常被BabelAutoprefixer等工具使用以生成兼容的代码
*/
const browsers = [
'last 2 Chrome versions',
'last 2 Firefox versions',
'last 3 Safari versions',
'last 2 Edge versions'
'last 2 Chrome versions', // 支持Chrome最新的2个版本
'last 2 Firefox versions', // 支持Firefox最新的2个版本
'last 3 Safari versions', // 支持Safari最新的3个版本
'last 2 Edge versions' // 支持Edge最新的2个版本
];
// 导出浏览器配置,供工具链使用
module.exports = {
browsers
};
};

@ -1,22 +1,55 @@
import {paginatedResponse} from '../utils';
import { paginatedResponse } from '../utils'; // 导入分页响应处理工具函数
/**
* 模拟标签Labels相关的API接口
* 用于前端开发时的本地数据模拟提供标签的CRUD操作接口
* @param {Object} server - Mirage JS服务器实例
*/
export default function mockLabels(server) {
/**
* 模拟创建标签的POST请求
* 路径/labels/
* 功能使用Mirage默认逻辑处理标签创建接收请求数据并返回新创建的标签
*/
server.post('/labels/');
/**
* 模拟查询标签列表的GET请求
* 路径/labels/
* 功能使用分页工具函数处理响应返回分页格式的标签列表
* 自动处理pagelimit等查询参数返回包含数据和分页元信息的响应
*/
server.get('/labels/', paginatedResponse('labels'));
server.get('/labels/:id/', function ({labels}, {params}) {
let {id} = params;
let label = labels.find(id);
/**
* 模拟查询单个标签的GET请求按ID
* 路径/labels/:id/
* 功能根据ID查询标签若不存在则返回404错误
*/
server.get('/labels/:id/', function ({ labels }, { params }) {
const { id } = params;
const label = labels.find(id); // 从模拟数据库中查找标签
// 若标签存在则返回否则返回404错误响应
return label || new Response(404, {}, {
errors: [{
type: 'NotFoundError',
message: 'Label not found.'
message: 'Label not found.' // 错误信息:标签未找到
}]
});
});
/**
* 模拟更新标签的PUT请求
* 路径/labels/:id/
* 功能使用Mirage默认逻辑处理标签更新根据ID更新标签属性并返回更新后的结果
*/
server.put('/labels/:id/');
/**
* 模拟删除标签的DELETE请求
* 路径/labels/:id/
* 功能使用Mirage默认逻辑处理标签删除根据ID删除标签并返回相应状态
*/
server.del('/labels/:id/');
}
}

@ -7,6 +7,15 @@ import { isBlank } from '@ember/utils'; // 工具函数:判断值是否为空
* 用于前端开发时的本地数据模拟无需依赖真实后端服务
* @param {Object} server - Mirage JS服务器实例
*/
/*
* Mirage tags API config
*
* 中文说明
* - 本模块在 Mirage 配置中定义了与标签相关的 REST 路由GET /tagsPOST /tags 以及
* 对应的处理逻辑目的是在开发或集成测试环境中模拟后端行为
* - 这些路由会调用 Mirage 的模型/工厂来创建查询更新或删除标签数据从而让前端组件
* 在没有真实后端时也能执行完整的交互流程
*/
export default function mockTags(server) {
/**
* 模拟创建标签的POST请求

@ -1,13 +1,57 @@
import moment from 'moment-timezone';
import {Factory} from 'miragejs';
import moment from 'moment-timezone'; // 导入moment时间处理库带时区支持
import { Factory } from 'miragejs'; // 导入Mirage JS的Factory类用于创建模拟数据工厂
/**
* 标签Label数据工厂
* 用于生成标准化的标签模拟数据供Mirage JS服务器使用
* 支持动态生成带索引的属性值模拟真实业务场景中的标签数据
*/
export default Factory.extend({
createdAt() { return moment.utc().toISOString(); },
name(i) { return `Label ${i}`; },
slug(i) { return `label-${i}`; },
updatedAt() { return moment.utc().toISOString(); },
/**
* 标签创建时间
* 动态生成当前UTC时间的ISO格式字符串"2024-05-20T12:34:56.789Z"
* @returns {string} ISO格式的UTC时间字符串
*/
createdAt() {
return moment.utc().toISOString();
},
/**
* 标签名称
* 动态生成包含当前标签索引i"Label 1"
* @param {number} i - 标签在工厂序列中的索引从1开始
* @returns {string} 带索引的标签名称
*/
name(i) {
return `Label ${i}`;
},
/**
* 标签URL别名slug
* 动态生成包含当前标签索引i"label-1"
* @param {number} i - 标签在工厂序列中的索引
* @returns {string} 带索引的slug
*/
slug(i) {
return `label-${i}`;
},
/**
* 标签更新时间
* 动态生成当前UTC时间的ISO格式字符串与创建时间一致模拟刚创建未更新的状态
* @returns {string} ISO格式的UTC时间字符串
*/
updatedAt() {
return moment.utc().toISOString();
},
/**
* 标签关联成员数量
* 默认为{members: 0}关联0个成员
* 实际使用中会被标签序列化器自动更新
* @returns {Object} 包含关联成员数量的对象
*/
count() {
// this gets updated automatically by the label serializer
return {members: 0};
return { members: 0 };
}
});
});

@ -1,18 +1,106 @@
import {Factory} from 'miragejs';
import { Factory } from 'miragejs'; // 引入Mirage JS的Factory类用于创建模拟数据工厂
/*
* Mirage Tag Factory
*
* 中文说明
* - 本工厂用于在开发模式或前端集成测试中生成标签Tag模拟数据
* - Mirage 会使用此工厂在内存数据库中创建标签记录以模拟后端返回的 API 数据
* - 这里生成的字段nameslugfeatureImagemetaTitlemetaDescription
* 用于保证前端组件与交互在没有真实后端的情况下也能正常工作与测试
*/
export default Factory.extend({
/**
* 标签创建时间
* 默认为固定时间2015-09-11T09:44:29.871Z
*/
createdAt: '2015-09-11T09:44:29.871Z',
description(i) { return `Description for tag ${i}.`; },
/**
* 标签描述
* 动态生成包含当前标签索引i"Description for tag 1."
* @param {number} i - 标签在工厂序列中的索引从1开始
* @returns {string} 带索引的描述文本
*/
description(i) {
return `Description for tag ${i}.`;
},
/**
* 标签可见性
* 默认为"public"公开
*/
visibility: 'public',
featureImage(i) { return `/content/images/2015/10/tag-${i}.jpg`; },
metaDescription(i) { return `Meta description for tag ${i}.`; },
metaTitle(i) { return `Meta Title for tag ${i}`; },
name(i) { return `Tag ${i}`; },
/**
* 标签封面图URL
* 动态生成包含当前标签索引i"/content/images/2015/10/tag-1.jpg"
* @param {number} i - 标签在工厂序列中的索引
* @returns {string} 带索引的图片URL
*/
featureImage(i) {
return `/content/images/2015/10/tag-${i}.jpg`;
},
/**
* 标签SEO描述meta description
* 动态生成包含当前标签索引i"Meta description for tag 1."
* @param {number} i - 标签在工厂序列中的索引
* @returns {string} 带索引的SEO描述文本
*/
metaDescription(i) {
return `Meta description for tag ${i}.`;
},
/**
* 标签SEO标题meta title
* 动态生成包含当前标签索引i"Meta Title for tag 1"
* @param {number} i - 标签在工厂序列中的索引
* @returns {string} 带索引的SEO标题文本
*/
metaTitle(i) {
return `Meta Title for tag ${i}`;
},
/**
* 标签名称
* 动态生成包含当前标签索引i"Tag 1"
* @param {number} i - 标签在工厂序列中的索引
* @returns {string} 带索引的标签名称
*/
name(i) {
return `Tag ${i}`;
},
/**
* 父标签
* 默认为null无父标签
*/
parent: null,
slug(i) { return `tag-${i}`; },
/**
* 标签URL别名slug
* 动态生成包含当前标签索引i"tag-1"
* @param {number} i - 标签在工厂序列中的索引
* @returns {string} 带索引的slug
*/
slug(i) {
return `tag-${i}`;
},
/**
* 标签更新时间
* 默认为固定时间2015-10-19T16:25:07.756Z
*/
updatedAt: '2015-10-19T16:25:07.756Z',
/**
* 标签关联内容数量
* 默认为{posts: 0}关联0篇文章
* 实际使用中会被标签序列化器自动更新
* @returns {Object} 包含关联文章数量的对象
*/
count() {
// this gets updated automatically by the tag serializer
return {posts: 0};
return { posts: 0 };
}
});
});

@ -1,5 +1,19 @@
import {Model, hasMany} from 'miragejs';
import { Model, hasMany } from 'miragejs'; // 导入Mirage JS的模型基础类和关联关系工具
/**
* 标签Label模型
* 定义标签与其他模型的关联关系用于Mirage JS模拟数据的关系管理
*/
export default Model.extend({
/**
* 定义标签与成员members的一对多关联关系
* 表示一个标签可以关联多个成员
*
* 关联说明
* - 采用hasMany关系当前标签Label拥有多个成员members
* - Mirage JS会自动管理关联数据的CRUD操作例如
* - 当查询标签时可以通过`label.members`获取关联的所有成员
* - 当创建成员并关联标签时标签的成员列表会自动更新
*/
members: hasMany()
});
});

@ -1,5 +1,28 @@
import {Model, hasMany} from 'miragejs';
/*
* Mirage Tag Model
*
* 中文说明
* - Mirage 中的 Tag 模型用于在前端开发与测试时模拟后端的标签数据模型
* - 通过 `hasMany('post')` 定义与文章posts的多对多/一对多关系方便在测试中通过 `tag.posts` 访问关联文章
* - Mirage 会自动维护关联数据如创建/删除时的引用更新因此测试可以更接近真实后端行为
*/
import { Model, hasMany } from 'miragejs'; // 导入Mirage JS的模型基础类和关联关系工具
/**
* 标签Tag模型
* 定义标签与文章posts的关联关系用于Mirage JS模拟数据的关系管理
*/
export default Model.extend({
/**
* 定义标签与文章的一对多关联关系
* 表示一个标签可以关联多篇文章
*
* 关联说明
* - 采用hasMany关系当前标签Tag拥有多篇文章posts
* - Mirage JS会自动维护关联数据例如
* - 查询标签时可通过`tag.posts`获取该标签关联的所有文章
* - 创建文章并关联标签时标签的文章列表会自动更新
* - 删除标签时可配置是否级联删除关联的文章默认不删除
*/
posts: hasMany()
});
});

@ -1,18 +1,36 @@
import BaseSerializer from './application';
import BaseSerializer from './application'; // 导入应用程序基础序列化器
/**
* 标签Label序列化器
* 继承自基础序列化器扩展了标签关联成员数量的动态计算逻辑
*/
export default BaseSerializer.extend({
// make the label.count.members value dynamic
/**
* 序列化标签模型或模型集合
* 动态更新标签的成员数量count.members确保与实际关联的成员数量一致
* @param {Model|Collection} labelModelOrCollection - 单个标签模型或标签集合
* @param {Object} request - 请求对象包含请求信息
* @returns {Object} 序列化后的标签数据符合API响应格式
*/
serialize(labelModelOrCollection, request) {
/**
* 更新单个标签的成员数量
* 将标签关联的成员ID数组长度作为实际成员数量更新到count.members字段
* @param {Model} label - 标签模型实例
*/
let updateMemberCount = (label) => {
label.update('count', {members: label.memberIds.length});
label.update('count', { members: label.memberIds.length });
};
// 若为单个标签模型,直接更新其成员数量
if (this.isModel(labelModelOrCollection)) {
updateMemberCount(labelModelOrCollection);
} else {
// 若为标签集合,遍历每个标签并更新成员数量
labelModelOrCollection.models.forEach(updateMemberCount);
}
// 调用父类的序列化方法,返回标准化的响应数据
return BaseSerializer.prototype.serialize.call(this, labelModelOrCollection, request);
}
});
});

@ -1,19 +1,45 @@
import BaseSerializer from './application';
/*
* Mirage Tag Serializer
*
* 中文说明
* - Mirage 中的 Tag 模型提供序列化逻辑在序列化阶段计算并注入关联文章数量count.posts
* 和访问 URLurl 字段使前端在渲染列表或详情时能正确显示关联计数与跳转链接
* - serialize 方法会支持传入单个模型或模型集合并为每个模型更新动态字段后再调用基类序列化
*/
export default BaseSerializer.extend({
// make the tag.count.posts and url values dynamic
/**
* 序列化标签模型或模型集合
* 会在序列化前动态更新标签的关联文章数量和访问URL
* @param {Model|Collection} tagModelOrCollection - 单个标签模型或标签集合
* @param {Object} request - 请求对象包含请求相关信息
* @returns {Object} 序列化后的标签数据符合API响应格式
*/
serialize(tagModelOrCollection, request) {
/**
* 更新单个标签的动态属性
* 1. 计算关联文章数量根据标签关联的文章ID数组长度
* 2. 生成访问URL结合本地开发服务器地址和标签的slug
* @param {Model} tag - 单个标签模型实例
*/
let updatePost = (tag) => {
// 更新关联文章数量postIds是Mirage自动维护的关联文章ID数组
tag.update('count', {posts: tag.postIds.length});
// 生成标签的访问URL基于本地开发环境地址
tag.update('url', `http://localhost:4200/tag/${tag.slug}/`);
};
// 判断传入的是单个模型还是模型集合
if (this.isModel(tagModelOrCollection)) {
// 若为单个模型,直接更新其动态属性
updatePost(tagModelOrCollection);
} else {
// 若为模型集合,遍历每个模型并更新动态属性
tagModelOrCollection.models.forEach(updatePost);
}
// 调用父类的serialize方法完成最终的序列化并返回结果
return BaseSerializer.prototype.serialize.call(this, tagModelOrCollection, request);
}
});
});

@ -1,361 +1,469 @@
import {Response} from 'miragejs';
import {authenticateSession, invalidateSession} from 'ember-simple-auth/test-support';
import {beforeEach, describe, it} from 'mocha';
import {click, currentRouteName, currentURL, fillIn, find, findAll} from '@ember/test-helpers';
import {expect} from 'chai';
import {setupApplicationTest} from 'ember-mocha';
import {setupMirage} from 'ember-cli-mirage/test-support';
import {visit} from '../helpers/visit';
import { Response } from 'miragejs';
// 导入Ember Simple Auth的测试支持工具用于认证和失效会话
import { authenticateSession, invalidateSession } from 'ember-simple-auth/test-support';
// 导入Mocha测试框架的钩子和断言方法
import { beforeEach, describe, it } from 'mocha';
// 导入Ember测试辅助函数用于模拟用户交互和获取DOM元素
import { click, currentRouteName, currentURL, fillIn, find, findAll } from '@ember/test-helpers';
// 导入Chai断言库
import { expect } from 'chai';
// 导入Ember应用测试设置工具
import { setupApplicationTest } from 'ember-mocha';
// 导入Mirage JS的测试支持工具
import { setupMirage } from 'ember-cli-mirage/test-support';
// 导入自定义的访问页面辅助函数
import { visit } from '../helpers/visit';
// 描述"标签Tags"验收测试套件
describe('Acceptance: Tags', function () {
// 设置应用测试钩子和Mirage模拟服务器
let hooks = setupApplicationTest();
setupMirage(hooks);
// 测试用例:未认证用户访问标签页时重定向到登录页
it('redirects to signin when not authenticated', async function () {
// 使当前会话失效(模拟未登录状态)
await invalidateSession();
// 访问标签页面
await visit('/tags');
// 断言当前URL为登录页
expect(currentURL()).to.equal('/signin');
});
// 测试用例贡献者Contributor角色用户访问标签页时重定向到文章页
it('redirects to posts page when authenticated as contributor', async function () {
let role = this.server.create('role', {name: 'Contributor'});
this.server.create('user', {roles: [role], slug: 'test-user'});
// 创建"Contributor"角色
let role = this.server.create('role', { name: 'Contributor' });
// 创建具有该角色的用户
this.server.create('user', { roles: [role], slug: 'test-user' });
// 认证当前会话(模拟登录)
await authenticateSession();
// 访问标签页面
await visit('/tags');
// 断言当前URL为文章页
expect(currentURL(), 'currentURL').to.equal('/posts');
});
// 测试用例作者Author角色用户访问标签页时重定向到站点设置页
it('redirects to site page when authenticated as author', async function () {
let role = this.server.create('role', {name: 'Author'});
this.server.create('user', {roles: [role], slug: 'test-user'});
// 创建"Author"角色
let role = this.server.create('role', { name: 'Author' });
// 创建具有该角色的用户
this.server.create('user', { roles: [role], slug: 'test-user' });
// 认证当前会话
await authenticateSession();
// 访问标签页面
await visit('/tags');
// 断言当前URL为站点设置页
expect(currentURL(), 'currentURL').to.equal('/site');
});
// 描述"以管理员Administrator身份登录"的测试场景
describe('when logged in as administrator', function () {
// 每个测试用例执行前的准备工作
beforeEach(async function () {
let role = this.server.create('role', {name: 'Administrator'});
this.server.create('user', {roles: [role]});
// 创建"Administrator"角色
let role = this.server.create('role', { name: 'Administrator' });
// 创建具有该角色的用户
this.server.create('user', { roles: [role] });
// 认证当前会话
await authenticateSession();
});
// 测试用例:分别列出公开标签和内部标签
it('lists public and internal tags separately', async function () {
this.server.create('tag', {name: 'B - Third', slug: 'third'});
this.server.create('tag', {name: 'Z - Last', slug: 'last'});
this.server.create('tag', {name: '!A - Second', slug: 'second'});
this.server.create('tag', {name: 'A - First', slug: 'first'});
this.server.create('tag', {name: '#one', slug: 'hash-one', visibility: 'internal'});
this.server.create('tag', {name: '#two', slug: 'hash-two', visibility: 'internal'});
// 创建4个公开标签
this.server.create('tag', { name: 'B - Third', slug: 'third' });
this.server.create('tag', { name: 'Z - Last', slug: 'last' });
this.server.create('tag', { name: '!A - Second', slug: 'second' });
this.server.create('tag', { name: 'A - First', slug: 'first' });
// 创建2个内部标签
this.server.create('tag', { name: '#one', slug: 'hash-one', visibility: 'internal' });
this.server.create('tag', { name: '#two', slug: 'hash-two', visibility: 'internal' });
// 访问标签页面
await visit('tags');
// it loads tags list
// 断言页面成功加载当前URL为标签页
expect(currentURL(), 'currentURL').to.equal('tags');
// it highlights nav menu
// 断言导航菜单中"标签"项处于激活状态
expect(find('[data-test-nav="tags"]'), 'highlights nav menu item')
.to.have.class('active');
// it defaults to public tags
// 断言默认显示公开标签(公开标签按钮处于激活状态)
expect(find('[data-test-tags-nav="public"]')).to.have.attr('data-test-active');
expect(find('[data-test-tags-nav="internal"]')).to.not.have.attr('data-test-active');
// it lists all public tags
// 断言公开标签列表数量为4
expect(findAll('[data-test-tag]'), 'public tag list count')
.to.have.length(4);
// tags are in correct order
// 断言标签按正确顺序排序(按名称排序)
let tags = findAll('[data-test-tag]');
expect(tags[0].querySelector('[data-test-tag-name]')).to.have.trimmed.text('A - First');
expect(tags[1].querySelector('[data-test-tag-name]')).to.have.trimmed.text('!A - Second');
expect(tags[2].querySelector('[data-test-tag-name]')).to.have.trimmed.text('B - Third');
expect(tags[3].querySelector('[data-test-tag-name]')).to.have.trimmed.text('Z - Last');
// can switch to internal tags
// 切换到内部标签视图
await click('[data-test-tags-nav="internal"]');
// 断言内部标签列表数量为2
expect(findAll('[data-test-tag]'), 'internal tag list count').to.have.length(2);
});
// 测试用例:可以添加标签
it('can add tags', async function () {
// 访问标签页面
await visit('tags');
// 断言初始时没有标签
expect(findAll('[data-test-tag]')).to.have.length(0);
// 点击"新建标签"按钮
await click('[data-test-button="new-tag"]');
// 断言跳转到新建标签页面
expect(currentURL()).to.equal('/tags/new');
// 填写标签名称和slug
await fillIn('[data-test-input="tag-name"]', 'New tag name');
await fillIn('[data-test-input="tag-slug"]', 'new-tag-slug');
// 点击保存按钮
await click('[data-test-button="save"]');
// 点击返回标签列表链接
await click('[data-test-link="tags-back"]');
// 断言标签列表中新增了一个标签
expect(findAll('[data-test-tag]')).to.have.length(1);
// 断言标签名称正确
expect(find('[data-test-tag] [data-test-tag-name]')).to.have.trimmed.text('New tag name');
// 断言标签slug正确
expect(find('[data-test-tag] [data-test-tag-slug]')).to.have.trimmed.text('new-tag-slug');
// 断言关联文章数量为0
expect(find('[data-test-tag] [data-test-tag-count]')).to.have.trimmed.text('0 posts');
});
// 测试用例:可以编辑标签
it('can edit tags', async function () {
const tag = this.server.create('tag', {name: 'To be edited', slug: 'to-be-edited'});
// 创建一个待编辑的标签
const tag = this.server.create('tag', { name: 'To be edited', slug: 'to-be-edited' });
// 访问标签页面
await visit('tags');
// 点击标签名称进入编辑页
await click(`[data-test-tag="${tag.id}"] [data-test-tag-name]`);
// it maintains active state in nav menu
// 断言导航菜单中"标签"项仍处于激活状态
expect(find('[data-test-nav="tags"]'), 'highlights nav menu item')
.to.have.class('active');
// 断言当前URL为标签编辑页
expect(currentURL()).to.equal('/tags/to-be-edited');
// 断言表单中初始值正确
expect(find('[data-test-input="tag-name"]')).to.have.value('To be edited');
expect(find('[data-test-input="tag-slug"]')).to.have.value('to-be-edited');
// 修改标签名称和slug
await fillIn('[data-test-input="tag-name"]', 'New tag name');
await fillIn('[data-test-input="tag-slug"]', 'new-tag-slug');
// 点击保存按钮
await click('[data-test-button="save"]');
// 从数据库中获取保存后的标签,断言数据已更新
const savedTag = this.server.db.tags.find(tag.id);
expect(savedTag.name, 'saved tag name').to.equal('New tag name');
expect(savedTag.slug, 'saved tag slug').to.equal('new-tag-slug');
// 点击返回标签列表链接
await click('[data-test-link="tags-back"]');
// 断言标签列表中显示更新后的标签信息
const tagListItem = find('[data-test-tag]');
expect(tagListItem.querySelector('[data-test-tag-name]')).to.have.trimmed.text('New tag name');
expect(tagListItem.querySelector('[data-test-tag-slug]')).to.have.trimmed.text('new-tag-slug');
});
// 测试用例:编辑标签时不会创建重复项
it('does not create duplicates when editing a tag', async function () {
const tag = this.server.create('tag', {name: 'To be edited', slug: 'to-be-edited'});
// 创建一个待编辑的标签
const tag = this.server.create('tag', { name: 'To be edited', slug: 'to-be-edited' });
// 访问标签页面
await visit('tags');
// Verify we start with one tag
// 断言初始时只有一个标签
expect(findAll('[data-test-tag]')).to.have.length(1);
// 点击标签名称进入编辑页
await click(`[data-test-tag="${tag.id}"] [data-test-tag-name]`);
// 修改标签名称
await fillIn('[data-test-input="tag-name"]', 'Edited Tag Name');
// 点击保存按钮
await click('[data-test-button="save"]');
// 点击返回标签列表链接
await click('[data-test-link="tags-back"]');
// Verify we still have only one tag after editing (no duplicates)
// 断言编辑后仍只有一个标签(无重复)
expect(findAll('[data-test-tag]')).to.have.length(1);
// 断言标签名称已更新
expect(find('[data-test-tag] [data-test-tag-name]')).to.have.trimmed.text('Edited Tag Name');
});
// 测试用例:可以删除标签
it('can delete tags', async function () {
const tag = this.server.create('tag', {name: 'To be edited', slug: 'to-be-edited'});
this.server.create('post', {tags: [tag]});
// 创建一个待删除的标签
const tag = this.server.create('tag', { name: 'To be edited', slug: 'to-be-edited' });
// 创建一篇关联该标签的文章
this.server.create('post', { tags: [tag] });
// 访问标签页面
await visit('tags');
// 点击标签名称进入编辑页
await click(`[data-test-tag="${tag.id}"] [data-test-tag-name]`);
// 点击删除标签按钮
await click('[data-test-button="delete-tag"]');
// 定义删除确认弹窗选择器
const tagModal = '[data-test-modal="confirm-delete-tag"]';
// 断言弹窗已显示
expect(find(tagModal)).to.exist;
// 断言弹窗中显示正确的关联文章数量
expect(find(`${tagModal} [data-test-text="posts-count"]`))
.to.have.trimmed.text('1 post');
// 点击确认删除按钮
await click(`${tagModal} [data-test-button="confirm"]`);
// 断言弹窗已关闭
expect(find(tagModal)).to.not.exist;
// 断言返回标签列表页
expect(currentURL()).to.equal('/tags');
// 断言标签已被删除(列表为空)
expect(findAll('[data-test-tag]')).to.have.length(0);
});
// 测试用例可以通过URL中的slug访问标签
it('can load tag via slug in url', async function () {
this.server.create('tag', {name: 'To be edited', slug: 'to-be-edited'});
// 创建一个标签
this.server.create('tag', { name: 'To be edited', slug: 'to-be-edited' });
// 直接通过slug访问标签编辑页
await visit('tags/to-be-edited');
// 断言当前URL正确
expect(currentURL()).to.equal('tags/to-be-edited');
// 断言表单中显示正确的标签信息
expect(find('[data-test-input="tag-name"]')).to.have.value('To be edited');
expect(find('[data-test-input="tag-slug"]')).to.have.value('to-be-edited');
});
// 测试用例访问不存在的标签时重定向到404页面
it('redirects to 404 when tag does not exist', async function () {
// 模拟请求不存在的标签时返回404错误
this.server.get('/tags/slug/unknown/', function () {
return new Response(404, {'Content-Type': 'application/json'}, {errors: [{message: 'Tag not found.', type: 'NotFoundError'}]});
return new Response(404, { 'Content-Type': 'application/json' }, {
errors: [{ message: 'Tag not found.', type: 'NotFoundError' }]
});
});
// 访问不存在的标签页面
await visit('tags/unknown');
// 断言当前路由为404错误页
expect(currentRouteName()).to.equal('error404');
// 断言URL保持不变显示错误的URL
expect(currentURL()).to.equal('/tags/unknown');
});
// 测试用例:创建新标签时导航菜单中"标签"项保持激活状态
it('maintains active state in nav menu when creating a new tag', async function () {
// 访问新建标签页面
await visit('tags/new');
// 断言当前URL正确
expect(currentURL()).to.equal('tags/new');
// 断言导航菜单中"标签"项处于激活状态
expect(find('[data-test-nav="tags"]'), 'highlights nav menu item')
.to.have.class('active');
});
});
// 描述"以编辑者Editor身份登录"的测试场景
describe('as an editor', function () {
// 每个测试用例执行前的准备工作
beforeEach(async function () {
let role = this.server.create('role', {name: 'Editor'});
this.server.create('user', {roles: [role]});
// 创建"Editor"角色
let role = this.server.create('role', { name: 'Editor' });
// 创建具有该角色的用户
this.server.create('user', { roles: [role] });
// 认证当前会话
await authenticateSession();
});
it('lists public and internal tags separately', async function () {
this.server.create('tag', {name: 'B - Third', slug: 'third'});
this.server.create('tag', {name: 'Z - Last', slug: 'last'});
this.server.create('tag', {name: '!A - Second', slug: 'second'});
this.server.create('tag', {name: 'A - First', slug: 'first'});
this.server.create('tag', {name: '#one', slug: 'hash-one', visibility: 'internal'});
this.server.create('tag', {name: '#two', slug: 'hash-two', visibility: 'internal'});
// 测试用例:分别列出公开标签和内部标签(与管理员权限一致)
it('lists public and internal tags separately', async function () {
// 创建4个公开标签和2个内部标签同管理员测试用例
this.server.create('tag', { name: 'B - Third', slug: 'third' });
this.server.create('tag', { name: 'Z - Last', slug: 'last' });
this.server.create('tag', { name: '!A - Second', slug: 'second' });
this.server.create('tag', { name: 'A - First', slug: 'first' });
this.server.create('tag', { name: '#one', slug: 'hash-one', visibility: 'internal' });
this.server.create('tag', { name: '#two', slug: 'hash-two', visibility: 'internal' });
// 访问标签页面
await visit('tags');
// it loads tags list
// 断言页面成功加载
expect(currentURL(), 'currentURL').to.equal('tags');
// it highlights nav menu
// 断言导航菜单激活状态
expect(find('[data-test-nav="tags"]'), 'highlights nav menu item')
.to.have.class('active');
// it defaults to public tags
// 断言默认显示公开标签
expect(find('[data-test-tags-nav="public"]')).to.have.attr('data-test-active');
expect(find('[data-test-tags-nav="internal"]')).to.not.have.attr('data-test-active');
// it lists all public tags
// 断言公开标签数量
expect(findAll('[data-test-tag]'), 'public tag list count')
.to.have.length(4);
// tags are in correct order
// 断言标签排序正确
let tags = findAll('[data-test-tag]');
expect(tags[0].querySelector('[data-test-tag-name]')).to.have.trimmed.text('A - First');
expect(tags[1].querySelector('[data-test-tag-name]')).to.have.trimmed.text('!A - Second');
expect(tags[2].querySelector('[data-test-tag-name]')).to.have.trimmed.text('B - Third');
expect(tags[3].querySelector('[data-test-tag-name]')).to.have.trimmed.text('Z - Last');
// can switch to internal tags
// 切换到内部标签
await click('[data-test-tags-nav="internal"]');
// 断言内部标签数量
expect(findAll('[data-test-tag]'), 'internal tag list count').to.have.length(2);
});
// 测试用例:可以编辑标签(与管理员权限一致)
it('can edit tags', async function () {
const tag = this.server.create('tag', {name: 'To be edited', slug: 'to-be-edited'});
// 创建待编辑标签
const tag = this.server.create('tag', { name: 'To be edited', slug: 'to-be-edited' });
// 访问标签页面并进入编辑页
await visit('tags');
await click(`[data-test-tag="${tag.id}"] [data-test-tag-name]`);
// it maintains active state in nav menu
// 断言导航菜单激活状态
expect(find('[data-test-nav="tags"]'), 'highlights nav menu item')
.to.have.class('active');
// 断言当前URL
expect(currentURL()).to.equal('/tags/to-be-edited');
// 断言初始表单值
expect(find('[data-test-input="tag-name"]')).to.have.value('To be edited');
expect(find('[data-test-input="tag-slug"]')).to.have.value('to-be-edited');
// 修改并保存标签
await fillIn('[data-test-input="tag-name"]', 'New tag name');
await fillIn('[data-test-input="tag-slug"]', 'new-tag-slug');
await click('[data-test-button="save"]');
// 断言数据已更新
const savedTag = this.server.db.tags.find(tag.id);
expect(savedTag.name, 'saved tag name').to.equal('New tag name');
expect(savedTag.slug, 'saved tag slug').to.equal('new-tag-slug');
// 返回列表页并断言显示正确
await click('[data-test-link="tags-back"]');
const tagListItem = find('[data-test-tag]');
expect(tagListItem.querySelector('[data-test-tag-name]')).to.have.trimmed.text('New tag name');
expect(tagListItem.querySelector('[data-test-tag-slug]')).to.have.trimmed.text('new-tag-slug');
});
// 测试用例:可以删除标签(与管理员权限一致)
it('can delete tags', async function () {
const tag = this.server.create('tag', {name: 'To be edited', slug: 'to-be-edited'});
this.server.create('post', {tags: [tag]});
// 创建待删除标签及关联文章
const tag = this.server.create('tag', { name: 'To be edited', slug: 'to-be-edited' });
this.server.create('post', { tags: [tag] });
// 访问标签页面并进入编辑页
await visit('tags');
await click(`[data-test-tag="${tag.id}"] [data-test-tag-name]`);
// 点击删除按钮
await click('[data-test-button="delete-tag"]');
const tagModal = '[data-test-modal="confirm-delete-tag"]';
// 断言弹窗显示及内容正确
expect(find(tagModal)).to.exist;
expect(find(`${tagModal} [data-test-text="posts-count"]`))
.to.have.trimmed.text('1 post');
// 确认删除
await click(`${tagModal} [data-test-button="confirm"]`);
// 断言标签已删除
expect(find(tagModal)).to.not.exist;
expect(currentURL()).to.equal('/tags');
expect(findAll('[data-test-tag]')).to.have.length(0);
});
});
// 描述"以超级编辑者Super Editor身份登录"的测试场景
describe('as a super editor', function () {
// 每个测试用例执行前的准备工作
beforeEach(async function () {
let role = this.server.create('role', {name: 'Super Editor'});
this.server.create('user', {roles: [role]});
// 创建"Super Editor"角色
let role = this.server.create('role', { name: 'Super Editor' });
// 创建具有该角色的用户
this.server.create('user', { roles: [role] });
// 认证当前会话
await authenticateSession();
});
it('lists public and internal tags separately', async function () {
this.server.create('tag', {name: 'B - Third', slug: 'third'});
this.server.create('tag', {name: 'Z - Last', slug: 'last'});
this.server.create('tag', {name: '!A - Second', slug: 'second'});
this.server.create('tag', {name: 'A - First', slug: 'first'});
this.server.create('tag', {name: '#one', slug: 'hash-one', visibility: 'internal'});
this.server.create('tag', {name: '#two', slug: 'hash-two', visibility: 'internal'});
// 测试用例:分别列出公开标签和内部标签(与管理员权限一致)
it('lists public and internal tags separately', async function () {
// 创建4个公开标签和2个内部标签同管理员测试用例
this.server.create('tag', { name: 'B - Third', slug: 'third' });
this.server.create('tag', { name: 'Z - Last', slug: 'last' });
this.server.create('tag', { name: '!A - Second', slug: 'second' });
this.server.create('tag', { name: 'A - First', slug: 'first' });
this.server.create('tag', { name: '#one', slug: 'hash-one', visibility: 'internal' });
this.server.create('tag', { name: '#two', slug: 'hash-two', visibility: 'internal' });
// 访问标签页面
await visit('tags');
// it loads tags list
// 断言页面加载及标签展示正确(与管理员测试用例一致)
expect(currentURL(), 'currentURL').to.equal('tags');
// it highlights nav menu
expect(find('[data-test-nav="tags"]'), 'highlights nav menu item')
.to.have.class('active');
// it defaults to public tags
expect(find('[data-test-tags-nav="public"]')).to.have.attr('data-test-active');
expect(find('[data-test-tags-nav="internal"]')).to.not.have.attr('data-test-active');
// it lists all public tags
expect(findAll('[data-test-tag]'), 'public tag list count')
.to.have.length(4);
// tags are in correct order
let tags = findAll('[data-test-tag]');
expect(tags[0].querySelector('[data-test-tag-name]')).to.have.trimmed.text('A - First');
expect(tags[1].querySelector('[data-test-tag-name]')).to.have.trimmed.text('!A - Second');
expect(tags[2].querySelector('[data-test-tag-name]')).to.have.trimmed.text('B - Third');
expect(tags[3].querySelector('[data-test-tag-name]')).to.have.trimmed.text('Z - Last');
// can switch to internal tags
await click('[data-test-tags-nav="internal"]');
expect(findAll('[data-test-tag]'), 'internal tag list count').to.have.length(2);
});
// 测试用例:可以编辑标签(与管理员权限一致)
it('can edit tags', async function () {
const tag = this.server.create('tag', {name: 'To be edited', slug: 'to-be-edited'});
// 创建待编辑标签(测试步骤与管理员测试用例一致)
const tag = this.server.create('tag', { name: 'To be edited', slug: 'to-be-edited' });
await visit('tags');
await click(`[data-test-tag="${tag.id}"] [data-test-tag-name]`);
// it maintains active state in nav menu
expect(find('[data-test-nav="tags"]'), 'highlights nav menu item')
.to.have.class('active');
expect(currentURL()).to.equal('/tags/to-be-edited');
expect(find('[data-test-input="tag-name"]')).to.have.value('To be edited');
expect(find('[data-test-input="tag-slug"]')).to.have.value('to-be-edited');
@ -368,21 +476,21 @@ describe('Acceptance: Tags', function () {
expect(savedTag.slug, 'saved tag slug').to.equal('new-tag-slug');
await click('[data-test-link="tags-back"]');
const tagListItem = find('[data-test-tag]');
expect(tagListItem.querySelector('[data-test-tag-name]')).to.have.trimmed.text('New tag name');
expect(tagListItem.querySelector('[data-test-tag-slug]')).to.have.trimmed.text('new-tag-slug');
});
// 测试用例:可以删除标签(与管理员权限一致)
it('can delete tags', async function () {
const tag = this.server.create('tag', {name: 'To be edited', slug: 'to-be-edited'});
this.server.create('post', {tags: [tag]});
// 创建待删除标签及关联文章(测试步骤与管理员测试用例一致)
const tag = this.server.create('tag', { name: 'To be edited', slug: 'to-be-edited' });
this.server.create('post', { tags: [tag] });
await visit('tags');
await click(`[data-test-tag="${tag.id}"] [data-test-tag-name]`);
await click('[data-test-button="delete-tag"]');
const tagModal = '[data-test-modal="confirm-delete-tag"]';
expect(find(tagModal)).to.exist;
@ -396,4 +504,4 @@ describe('Acceptance: Tags', function () {
expect(findAll('[data-test-tag]')).to.have.length(0);
});
});
});
});

@ -1,37 +1,63 @@
/**
* 启用指定的实验室功能标志Labs Flag
* 用于在测试环境中开启特定的实验性功能
* @param {Object} server - Mirage JS服务器实例
* @param {string} flag - 要启用的实验室功能标志名称
*/
export function enableLabsFlag(server, flag) {
// 若配置表为空,加载配置 fixtures 数据
if (!server.schema.configs.all().length) {
server.loadFixtures('configs');
}
// 若设置表为空,加载设置 fixtures 数据
if (!server.schema.settings.all().length) {
server.loadFixtures('settings');
}
// 获取第一个配置项,开启开发者实验功能总开关
const config = server.schema.configs.first();
config.update({enableDeveloperExperiments: true});
config.update({ enableDeveloperExperiments: true });
const existingSetting = server.db.settings.findBy({key: 'labs'}).value;
// 获取现有的实验室功能设置JSON字符串
const existingSetting = server.db.settings.findBy({ key: 'labs' }).value;
// 解析为对象(若不存在则初始化为空对象)
const labsSetting = existingSetting ? JSON.parse(existingSetting) : {};
// 启用目标功能标志
labsSetting[flag] = true;
server.db.settings.update({key: 'labs'}, {value: JSON.stringify(labsSetting)});
// 更新数据库中的实验室设置转换为JSON字符串存储
server.db.settings.update({ key: 'labs' }, { value: JSON.stringify(labsSetting) });
}
/**
* 禁用指定的实验室功能标志Labs Flag
* 用于在测试环境中关闭特定的实验性功能
* @param {Object} server - Mirage JS服务器实例
* @param {string} flag - 要禁用的实验室功能标志名称
*/
export function disableLabsFlag(server, flag) {
// 若配置表为空,加载配置 fixtures 数据
if (!server.schema.configs.all().length) {
server.loadFixtures('configs');
}
// 若设置表为空,加载设置 fixtures 数据
if (!server.schema.settings.all().length) {
server.loadFixtures('settings');
}
// 获取第一个配置项,确保开发者实验功能总开关开启(避免功能被全局禁用)
const config = server.schema.configs.first();
config.update({enableDeveloperExperiments: true});
config.update({ enableDeveloperExperiments: true });
const existingSetting = server.db.settings.findBy({key: 'labs'}).value;
// 获取现有的实验室功能设置JSON字符串
const existingSetting = server.db.settings.findBy({ key: 'labs' }).value;
// 解析为对象(若不存在则初始化为空对象)
const labsSetting = existingSetting ? JSON.parse(existingSetting) : {};
// 禁用目标功能标志
labsSetting[flag] = false;
server.db.settings.update({key: 'labs'}, {value: JSON.stringify(labsSetting)});
}
// 更新数据库中的实验室设置转换为JSON字符串存储
server.db.settings.update({ key: 'labs' }, { value: JSON.stringify(labsSetting) });
}

@ -1,60 +1,92 @@
import Pretender from 'pretender';
import ghostPaths from 'ghost-admin/utils/ghost-paths';
import {describe, it} from 'mocha';
import {expect} from 'chai';
import {setupTest} from 'ember-mocha';
import Pretender from 'pretender'; // 导入Pretender库用于模拟HTTP请求
import ghostPaths from 'ghost-admin/utils/ghost-paths'; // 导入Ghost路径工具用于获取API根路径
import { describe, it } from 'mocha'; // 导入Mocha测试框架的描述和测试用例函数
import { expect } from 'chai'; // 导入Chai断言库
import { setupTest } from 'ember-mocha'; // 导入Ember测试设置工具
// 描述"标签适配器Adapter: tag"的集成测试套件
describe('Integration: Adapter: tag', function () {
// 设置测试环境初始化Ember测试容器
setupTest();
// 声明变量:模拟服务器和数据存储服务
let server, store;
// 每个测试用例执行前的准备工作
beforeEach(function () {
// 获取Ember的数据存储服务store
store = this.owner.lookup('service:store');
// 创建Pretender模拟服务器实例用于拦截和模拟API请求
server = new Pretender();
});
// 每个测试用例执行后的清理工作
afterEach(function () {
// 关闭模拟服务器,避免影响其他测试
server.shutdown();
});
// 测试用例获取所有标签时从常规API端点加载数据
it('loads tags from regular endpoint when all are fetched', function (done) {
// 模拟GET请求当请求标签列表API时返回预设的标签数据
server.get(`${ghostPaths().apiRoot}/tags/`, function () {
return [200, {'Content-Type': 'application/json'}, JSON.stringify({tags: [
{
id: 1,
name: 'Tag 1',
slug: 'tag-1'
}, {
id: 2,
name: 'Tag 2',
slug: 'tag-2'
}
]})];
return [
200, // HTTP状态码成功
{ 'Content-Type': 'application/json' }, // 响应头JSON格式
JSON.stringify({ // 响应体:包含两个标签的数组
tags: [
{
id: 1,
name: 'Tag 1',
slug: 'tag-1'
}, {
id: 2,
name: 'Tag 2',
slug: 'tag-2'
}
]
})
];
});
store.findAll('tag', {reload: true}).then((tags) => {
// 使用store查询所有标签强制重新加载
store.findAll('tag', { reload: true }).then((tags) => {
// 断言:查询结果存在
expect(tags).to.be.ok;
// 断言:第一个标签的名称正确
expect(tags.objectAtContent(0).get('name')).to.equal('Tag 1');
// 标记测试完成
done();
});
});
// 测试用例查询单个标签且传入slug时从slug专属API端点加载数据
it('loads tag from slug endpoint when single tag is queried and slug is passed in', function (done) {
// 模拟GET请求当请求特定slug的标签API时返回预设的标签数据
server.get(`${ghostPaths().apiRoot}/tags/slug/tag-1/`, function () {
return [200, {'Content-Type': 'application/json'}, JSON.stringify({tags: [
{
id: 1,
slug: 'tag-1',
name: 'Tag 1'
}
]})];
return [
200, // HTTP状态码成功
{ 'Content-Type': 'application/json' }, // 响应头JSON格式
JSON.stringify({ // 响应体包含指定slug的标签
tags: [
{
id: 1,
slug: 'tag-1',
name: 'Tag 1'
}
]
})
];
});
store.queryRecord('tag', {slug: 'tag-1'}).then((tag) => {
// 使用store按slug查询单个标签
store.queryRecord('tag', { slug: 'tag-1' }).then((tag) => {
// 断言:查询结果存在
expect(tag).to.be.ok;
// 断言:标签名称正确
expect(tag.get('name')).to.equal('Tag 1');
// 标记测试完成
done();
});
});
});
});

@ -1,253 +1,358 @@
import hbs from 'htmlbars-inline-precompile';
import mockPosts from '../../../mirage/config/posts';
import mockTags from '../../../mirage/config/themes';
import {click, find, findAll, render, settled, waitUntil} from '@ember/test-helpers';
import {clickTrigger, selectChoose, typeInSearch} from 'ember-power-select/test-support/helpers';
import {describe, it} from 'mocha';
import {expect} from 'chai';
import {setupRenderingTest} from 'ember-mocha';
import {startMirage} from 'ghost-admin/initializers/ember-cli-mirage';
import {timeout} from 'ember-concurrency';
// NOTE: although Mirage has posts<->tags relationship and can respond
// to :post-id/?include=tags all ordering information is lost so we
// need to build the tags array manually
import { click, find, findAll, render, settled, waitUntil } from '@ember/test-helpers';
import { clickTrigger, selectChoose, typeInSearch } from 'ember-power-select/test-support/helpers';
import { describe, it } from 'mocha';
import { expect } from 'chai';
import { setupRenderingTest } from 'ember-mocha';
import { startMirage } from 'ghost-admin/initializers/ember-cli-mirage';
import { timeout } from 'ember-concurrency';
// 注意尽管Mirage中存在文章与标签的关联关系且能响应带include=tags的请求
//
// 中文说明:
// - 本集成测试依赖 Mirage 提供的 tags mock 数据及 API 路由用于验证标签输入组件GhPsmTagsInput
// 在不同场景下(渲染已选标签、本地/服务端搜索、创建/删除标签等)的行为是否正确。
// - Mirage 在模拟时可能丢失后端在排序/pagination 上的一些真实细节(例如特定排序字段),
// 因此测试中在需要断言顺序或构建复杂关联时,会手动调整或重建标签数组以保证一致性。
// - 当修改 Mirage 工厂/序列化器或标签相关 API 时,请同时更新此测试以保持兼容性。
const assignPostWithTags = async function postWithTags(context, ...slugs) {
// 获取ID为1的文章
let post = await context.store.findRecord('post', 1);
// 获取所有标签
let tags = await context.store.findAll('tag');
// 为文章添加指定slug的标签
slugs.forEach((slug) => {
post.get('tags').pushObject(tags.findBy('slug', slug));
});
// 将文章设置到测试上下文并等待异步操作完成
context.set('post', post);
await settled();
};
// 描述"标签输入组件GhPsmTagsInput"的集成测试套件
describe('Integration: Component: gh-psm-tags-input', function () {
// 设置渲染测试环境
setupRenderingTest();
// 声明模拟服务器变量
let server;
// 每个测试用例执行前的准备工作
beforeEach(function () {
// 启动Mirage模拟服务器
server = startMirage();
// 创建作者用户
let author = server.create('user');
// 加载文章和标签的模拟数据配置
mockPosts(server);
mockTags(server);
server.create('post', {authors: [author]});
server.create('tag', {name: 'Tag 1', slug: 'one'});
server.create('tag', {name: '#Tag 2', visibility: 'internal', slug: 'two'});
server.create('tag', {name: 'Tag 3', slug: 'three'});
server.create('tag', {name: 'Tag 4', slug: 'four'});
// 创建关联作者的文章
server.create('post', { authors: [author] });
// 创建测试标签
server.create('tag', { name: 'Tag 1', slug: 'one' });
server.create('tag', { name: '#Tag 2', visibility: 'internal', slug: 'two' });
server.create('tag', { name: 'Tag 3', slug: 'three' });
server.create('tag', { name: 'Tag 4', slug: 'four' });
// 将数据存储服务设置到测试上下文
this.set('store', this.owner.lookup('service:store'));
});
// 每个测试用例执行后的清理工作
afterEach(function () {
// 关闭模拟服务器
server.shutdown();
});
// 测试用例:渲染时显示已选择的标签
it('shows selected tags on render', async function () {
// 为文章分配标签"one"和"three"
await assignPostWithTags(this, 'one', 'three');
// 渲染标签输入组件
await render(hbs`<GhPsmTagsInput @post={{post}} />`);
// 获取所有已选择的标签令牌
let selected = findAll('.tag-token');
// 断言显示2个已选择的标签
expect(selected.length).to.equal(2);
// 断言:标签文本正确
expect(selected[0]).to.contain.text('Tag 1');
expect(selected[1]).to.contain.text('Tag 3');
});
// skipped because FF 85 on Linux (CI) is failing. FF 85 on mac is fine.
// possible difference in `localeCompare()` across systems
// 测试用例:以字母顺序显示所有标签选项(因浏览器兼容性问题暂时跳过)
// 跳过原因Linux上的FF 85CI环境失败mac上的FF 85正常
// 可能是不同系统上`localeCompare()`的实现差异导致
it.skip('exposes all tags as options sorted alphabetically', async function () {
// 获取ID为1的文章并设置到测试上下文
this.set('post', this.store.findRecord('post', 1));
await settled();
// 渲染标签输入组件
await render(hbs`<GhPsmTagsInput @post={{post}} />`);
// 点击触发下拉选项
await clickTrigger();
await settled();
// unsure why settled() is sometimes not catching the update
// 不确定为什么settled()有时无法捕获更新,添加短暂延迟
await timeout(100);
// 获取所有选项
let options = findAll('.ember-power-select-option');
// 断言显示4个标签选项
expect(options.length).to.equal(4);
// 断言:选项按字母顺序排列
expect(options[0]).to.contain.text('Tag 1');
expect(options[1]).to.contain.text('#Tag 2');
expect(options[2]).to.contain.text('Tag 3');
expect(options[3]).to.contain.text('Tag 4');
});
// 测试用例:如果第一页已加载所有标签,则使用本地搜索
it('uses local search if all tags have been loaded in first page', async function () {
// 获取ID为1的文章并设置到测试上下文
this.set('post', this.store.findRecord('post', 1));
await settled();
// 渲染标签输入组件
await render(hbs`<GhPsmTagsInput @post={{post}} />`);
// 点击触发下拉选项
await clickTrigger();
await settled();
// 记录当前请求次数
const requestCount = server.pretender.handledRequests.length;
// 等待选项加载完成
await waitUntil(() => findAll('.ember-power-select-option').length >= 4);
// 输入搜索关键词
await typeInSearch('2');
await settled();
// 断言:搜索未触发新的请求(使用本地搜索)
expect(server.pretender.handledRequests.length).to.equal(requestCount);
});
// 测试用例:如果通过滚动加载了所有标签,则使用本地搜索
it('uses local search if all tags have been loaded by scrolling', async function () {
// create > 1 page of tags. Left-pad the names to ensure they're sorted alphabetically
server.db.tags.remove(); // clear existing tags that will mess with alphabetical sorting
server.createList('tag', 150, {name: i => `Tag ${i.toString().padStart(3, '0')}`});
// 创建超过1页的标签150个。左填充名称以确保按字母顺序排序
server.db.tags.remove(); // 清除可能干扰字母排序的现有标签
server.createList('tag', 150, { name: i => `Tag ${i.toString().padStart(3, '0')}` });
// 获取ID为1的文章并设置到测试上下文
this.set('post', this.store.findRecord('post', 1));
await settled();
// 渲染标签输入组件
await render(hbs`<GhPsmTagsInput @post={{post}} />`);
// 点击触发下拉选项
await clickTrigger();
// although we load 100 per page, we'll never have more 50 options rendered
// because we use vertical-collection to recycle dom elements on scroll
await waitUntil(() => findAll('.ember-power-select-option').length >= 50, {timeoutMessage: 'Timed out waiting for first page loaded state'});
// 尽管每页加载100个但由于使用vertical-collection回收DOM元素最多显示50个选项
await waitUntil(
() => findAll('.ember-power-select-option').length >= 50,
{ timeoutMessage: '等待第一页加载超时' }
);
// scroll to the bottom of the options to load the next page
// 滚动到选项底部以加载下一页
const optionsContent = find('.ember-power-select-options');
optionsContent.scrollTo({top: optionsContent.scrollHeight});
optionsContent.scrollTo({ top: optionsContent.scrollHeight });
await settled();
// wait for second page to be loaded
await waitUntil(() => server.pretender.handledRequests.some(r => r.queryParams.page === '2'));
optionsContent.scrollTo({top: optionsContent.scrollHeight});
await waitUntil(() => findAll('.ember-power-select-option').some(o => o.textContent.includes('Tag 105')), {timeoutMessage: 'Timed out waiting for second page loaded state'});
// capture current request count - we test that it doesn't change to indicate a client-side filter
// 等待第二页加载完成
await waitUntil(
() => server.pretender.handledRequests.some(r => r.queryParams.page === '2')
);
optionsContent.scrollTo({ top: optionsContent.scrollHeight });
await waitUntil(
() => findAll('.ember-power-select-option').some(o => o.textContent.includes('Tag 105')),
{ timeoutMessage: '等待第二页加载超时' }
);
// 记录当前请求次数 - 测试是否未发送新请求(表明使用客户端过滤)
const requestCount = server.pretender.handledRequests.length;
// 输入搜索关键词
await typeInSearch('21');
await settled();
// wait until we're sure we've filtered
await waitUntil(() => findAll('.ember-power-select-option').length <= 5, {timeoutMessage: 'Timed out waiting for filtered state'});
// 等待过滤完成
await waitUntil(
() => findAll('.ember-power-select-option').length <= 5,
{ timeoutMessage: '等待过滤状态超时' }
);
// request count should not increase if we've used client-side filtering
// 断言:请求次数未增加(使用客户端过滤)
expect(server.pretender.handledRequests.length).to.equal(requestCount);
});
// 描述"客户端搜索"的测试场景
describe('client-side search', function () {
// 测试用例:匹配小写标签名称的选项
it('matches options on lowercase tag names', async function () {
// 获取ID为1的文章并设置到测试上下文
this.set('post', this.store.findRecord('post', 1));
await settled();
// 渲染标签输入组件
await render(hbs`<GhPsmTagsInput @post={{post}} />`);
// 点击触发下拉选项并输入搜索关键词
await clickTrigger();
await typeInSearch('2');
await settled();
// unsure why settled() is sometimes not catching the update
// 不确定为什么settled()有时无法捕获更新,添加短暂延迟
await timeout(100);
// 获取所有选项
let options = findAll('.ember-power-select-option');
// 断言显示2个匹配选项
expect(options.length).to.equal(2);
// 断言:选项包含"Add "2"..."和"Tag 2"
expect(options[0]).to.contain.text('Add "2"...');
expect(options[1]).to.contain.text('Tag 2');
});
// 测试用例:精确匹配时隐藏创建选项
it('hides create option on exact matches', async function () {
// 获取ID为1的文章并设置到测试上下文
this.set('post', this.store.findRecord('post', 1));
await settled();
// 渲染标签输入组件
await render(hbs`<GhPsmTagsInput @post={{post}} />`);
// 点击触发下拉选项并输入精确匹配的关键词
await clickTrigger();
await typeInSearch('#Tag 2');
await settled();
// unsure why settled() is sometimes not catching the update
// 不确定为什么settled()有时无法捕获更新,添加短暂延迟
await timeout(100);
// 获取所有选项
let options = findAll('.ember-power-select-option');
// 断言只显示1个精确匹配的选项
expect(options.length).to.equal(1);
expect(options[0]).to.contain.text('#Tag 2');
});
// 测试用例:可以搜索包含单引号的标签
it('can search for tags with single quotes', async function () {
server.create('tag', {name: 'O\'Nolan', slug: 'quote-test'});
// 创建包含单引号的标签
server.create('tag', { name: 'O\'Nolan', slug: 'quote-test' });
// 获取ID为1的文章并设置到测试上下文
this.set('post', this.store.findRecord('post', 1));
await settled();
// 渲染标签输入组件
await render(hbs`<GhPsmTagsInput @post={{post}} />`);
// 点击触发下拉选项并输入包含单引号的搜索关键词
await clickTrigger();
await typeInSearch(`O'`);
await settled();
// 获取所有选项
let options = findAll('.ember-power-select-option');
// 断言显示2个匹配选项
expect(options.length).to.equal(2);
expect(options[0]).to.contain.text(`Add "O'"...`);
expect(options[1]).to.contain.text(`O'Nolan`);
});
});
// 描述"服务器端搜索"的测试场景(暂未实现测试用例)
describe('server-side search', function () {
});
// 测试用例:高亮显示内部标签
it('highlights internal tags', async function () {
// 为文章分配标签"two"(内部标签)和"three"
await assignPostWithTags(this, 'two', 'three');
// 渲染标签输入组件
await render(hbs`<GhPsmTagsInput @post={{post}} />`);
// 获取所有已选择的标签令牌
let selected = findAll('.tag-token');
// 断言显示2个已选择的标签
expect(selected.length).to.equal(2);
// 断言:内部标签有特殊样式类,普通标签没有
expect(selected[0]).to.have.class('tag-token--internal');
expect(selected[1]).to.not.have.class('tag-token--internal');
});
// 描述"更新标签updateTags"的测试场景
describe('updateTags', function () {
// 测试用例:修改文章的标签列表
it('modifies post.tags', async function () {
// 为文章分配标签"two"和"three"
await assignPostWithTags(this, 'two', 'three');
// 渲染标签输入组件
await render(hbs`<GhPsmTagsInput @post={{post}} />`);
// 选择"Tag 1"标签
await selectChoose('.ember-power-select-trigger', 'Tag 1');
// 断言:文章的标签列表已更新
expect(
this.post.tags.mapBy('name').join(',')
).to.equal('#Tag 2,Tag 3,Tag 1');
});
// TODO: skipped due to consistently random failures on Travis
// '#ember-basic-dropdown-content-ember17494 Add "New"...' is not a valid selector
// https://github.com/TryGhost/Ghost/issues/10308
// 测试用例未选中时销毁新标签记录因Travis上持续随机失败暂时跳过
// 失败原因:选择器无效 '#ember-basic-dropdown-content-ember17494 Add "New"...'
// 相关Issuehttps://github.com/TryGhost/Ghost/issues/10308
it.skip('destroys new tag records when not selected', async function () {
// 为文章分配标签"two"和"three"
await assignPostWithTags(this, 'two', 'three');
// 渲染标签输入组件
await render(hbs`<GhPsmTagsInput @post={{post}} />`);
// 点击触发下拉选项,输入新标签名称并选择创建选项
await clickTrigger();
await typeInSearch('New');
await settled();
await selectChoose('.ember-power-select-trigger', 'Add "New"...');
// 断言标签数量增加到5原有4个+新增1个
let tags = await this.store.peekAll('tag');
expect(tags.length).to.equal(5);
// 点击移除最后一个标签(新创建的标签)
let removeBtns = findAll('.ember-power-select-multiple-remove-btn');
await click(removeBtns[removeBtns.length - 1]);
// 断言新标签记录已被销毁标签数量回到4
tags = await this.store.peekAll('tag');
expect(tags.length).to.equal(4);
});
});
// 描述"创建标签createTag"的测试场景
describe('createTag', function () {
// 测试用例:创建新的标签记录
it('creates new records', async function () {
// 为文章分配标签"two"和"three"
await assignPostWithTags(this, 'two', 'three');
// 渲染标签输入组件
await render(hbs`<GhPsmTagsInput @post={{post}} />`);
// 点击触发下拉选项,输入第一个新标签名称并选择创建选项
await clickTrigger();
await typeInSearch('New One');
await settled();
await selectChoose('.ember-power-select-trigger', '.ember-power-select-option', 0);
// 输入第二个新标签名称并选择创建选项
await typeInSearch('New Two');
await settled();
await selectChoose('.ember-power-select-trigger', '.ember-power-select-option', 0);
// 断言标签数量增加到6原有4个+新增2个
let tags = await this.store.peekAll('tag');
expect(tags.length).to.equal(6);
// 断言新创建的标签记录处于isNew状态未保存到服务器
expect(tags.findBy('name', 'New One').isNew).to.be.true;
expect(tags.findBy('name', 'New Two').isNew).to.be.true;
});
});
});
});

@ -1,13 +1,23 @@
import {click, findAll, render, triggerKeyEvent} from '@ember/test-helpers';
import {describe, it} from 'mocha';
import {expect} from 'chai';
import {hbs} from 'ember-cli-htmlbars';
import {setupRenderingTest} from 'ember-mocha';
import { click, findAll, render, triggerKeyEvent } from '@ember/test-helpers';
import { describe, it } from 'mocha';
import { expect } from 'chai';
import { hbs } from 'ember-cli-htmlbars';
import { setupRenderingTest } from 'ember-mocha';
/**
* 标签页组件Tabs::Tabs的集成测试
* 验证组件的渲染效果交互行为及特殊配置的功能
*/
describe('Integration: Component: tabs/tabs', function () {
// 设置渲染测试环境
setupRenderingTest();
/**
* 测试组件基础渲染效果
* 验证初始状态下标签按钮面板的数量选中状态及内容
*/
it('renders', async function () {
// 渲染标签页组件包含2个标签和对应的面板
await render(hbs`
<Tabs::Tabs class="test-tab" as |tabs|>
<tabs.tab>Tab 1</tabs.tab>
@ -17,27 +27,37 @@ describe('Integration: Component: tabs/tabs', function () {
<tabs.tabPanel>Content 2</tabs.tabPanel>
</Tabs::Tabs>`);
// 获取标签按钮和面板元素
const tabButtons = findAll('.tab');
const tabPanels = findAll('.tab-panel');
expect(findAll('.test-tab').length).to.equal(1);
expect(findAll('.tab-list').length).to.equal(1);
expect(tabPanels.length).to.equal(2);
expect(tabButtons.length).to.equal(2);
// 验证组件容器和列表结构
expect(findAll('.test-tab').length).to.equal(1); // 组件容器存在
expect(findAll('.tab-list').length).to.equal(1); // 标签列表容器存在
expect(tabPanels.length).to.equal(2); // 面板数量正确
expect(tabButtons.length).to.equal(2); // 标签按钮数量正确
expect(findAll('.tab-selected').length).to.equal(1);
expect(findAll('.tab-panel-selected').length).to.equal(1);
expect(tabButtons[0]).to.have.class('tab-selected');
expect(tabPanels[0]).to.have.class('tab-panel-selected');
// 验证初始选中状态
expect(findAll('.tab-selected').length).to.equal(1); // 只有一个选中的标签
expect(findAll('.tab-panel-selected').length).to.equal(1); // 只有一个选中的面板
expect(tabButtons[0]).to.have.class('tab-selected'); // 第一个标签默认选中
expect(tabPanels[0]).to.have.class('tab-panel-selected'); // 第一个面板默认选中
// 验证标签按钮文本
expect(tabButtons[0]).to.have.trimmed.text('Tab 1');
expect(tabButtons[1]).to.have.trimmed.text('Tab 2');
// 验证面板内容(未选中的面板内容为空)
expect(tabPanels[0]).to.have.trimmed.text('Content 1');
expect(tabPanels[1]).to.have.trimmed.text('');
});
/**
* 测试点击标签时的交互效果
* 验证点击后标签和面板的选中状态及内容切换
*/
it('renders expected content on click', async function () {
// 渲染标签页组件
await render(hbs`
<Tabs::Tabs class="test-tab" as |tabs|>
<tabs.tab>Tab 1</tabs.tab>
@ -50,18 +70,26 @@ describe('Integration: Component: tabs/tabs', function () {
const tabButtons = findAll('.tab');
const tabPanels = findAll('.tab-panel');
// 点击第二个标签
await click(tabButtons[1]);
// 验证选中状态切换
expect(findAll('.tab-selected').length).to.equal(1);
expect(findAll('.tab-panel-selected').length).to.equal(1);
expect(tabButtons[1]).to.have.class('tab-selected');
expect(tabPanels[1]).to.have.class('tab-panel-selected');
expect(tabButtons[1]).to.have.class('tab-selected'); // 第二个标签被选中
expect(tabPanels[1]).to.have.class('tab-panel-selected'); // 第二个面板被选中
// 验证面板内容切换(未选中的面板内容为空)
expect(tabPanels[0]).to.have.trimmed.text('');
expect(tabPanels[1]).to.have.trimmed.text('Content 2');
});
/**
* 测试键盘事件对标签的控制
* 验证方向键HomeEnd键的导航功能
*/
it('renders expected content on keyup event', async function () {
// 渲染包含3个标签的组件
await render(hbs`
<Tabs::Tabs class="test-tab" as |tabs|>
<tabs.tab>Tab 0</tabs.tab>
@ -76,34 +104,45 @@ describe('Integration: Component: tabs/tabs', function () {
const tabButtons = findAll('.tab');
const tabPanels = findAll('.tab-panel');
// 辅助函数:验证指定索引的标签和面板处于选中状态且内容正确
const isTabRenders = (num) => {
expect(tabButtons[num]).to.have.class('tab-selected');
expect(tabPanels[num]).to.have.class('tab-panel-selected');
expect(tabPanels[num]).to.have.trimmed.text(`Content ${num}`);
};
// 右方向键导航从0→1→2
await triggerKeyEvent(tabButtons[0], 'keyup', 'ArrowRight');
await triggerKeyEvent(tabButtons[1], 'keyup', 'ArrowRight');
isTabRenders(2);
// 右方向键循环从2→0
await triggerKeyEvent(tabButtons[2], 'keyup', 'ArrowRight');
isTabRenders(0);
// 左方向键导航从0→2
await triggerKeyEvent(tabButtons[0], 'keyup', 'ArrowLeft');
isTabRenders(2);
// 左方向键导航从2→1
await triggerKeyEvent(tabButtons[2], 'keyup', 'ArrowLeft');
isTabRenders(1);
// Home键跳转到第一个标签
await triggerKeyEvent(tabButtons[0], 'keyup', 'Home');
isTabRenders(0);
// End键跳转到最后一个标签
await triggerKeyEvent(tabButtons[0], 'keyup', 'End');
isTabRenders(2);
});
/**
* 测试forceRender参数的效果
* 验证开启后所有面板内容始终渲染不随选中状态隐藏
*/
it('renders content for all tabs with forceRender option', async function () {
// 渲染开启forceRender的标签页组件
await render(hbs`
<Tabs::Tabs class="test-tab" @forceRender={{true}} as |tabs|>
<tabs.tab>Tab 1</tabs.tab>
@ -116,17 +155,21 @@ describe('Integration: Component: tabs/tabs', function () {
const tabButtons = findAll('.tab');
const tabPanels = findAll('.tab-panel');
// 初始状态:所有面板内容都渲染
expect(tabPanels[0]).to.have.trimmed.text('Content 1');
expect(tabPanels[1]).to.have.trimmed.text('Content 2');
// 点击第二个标签
await click(tabButtons[1]);
// 选中状态正常切换
expect(findAll('.tab-selected').length).to.equal(1);
expect(findAll('.tab-panel-selected').length).to.equal(1);
expect(tabButtons[1]).to.have.class('tab-selected');
expect(tabPanels[1]).to.have.class('tab-panel-selected');
// 所有面板内容仍保持渲染forceRender生效
expect(tabPanels[0]).to.have.trimmed.text('Content 1');
expect(tabPanels[1]).to.have.trimmed.text('Content 2');
});
});
});

@ -1,29 +1,36 @@
// TODO: remove usage of Ember Data's private `Errors` class when refactoring validations
// TODO: 重构验证逻辑时移除对Ember Data私有类`Errors`的使用
// eslint-disable-next-line
import DS from 'ember-data';
import EmberObject from '@ember/object';
import Service from '@ember/service';
import hbs from 'htmlbars-inline-precompile';
import {blur, click, fillIn, find, findAll, render} from '@ember/test-helpers';
import {describe, it} from 'mocha';
import {expect} from 'chai';
import {setupRenderingTest} from 'ember-mocha';
import { blur, click, fillIn, find, findAll, render } from '@ember/test-helpers';
import { describe, it } from 'mocha';
import { expect } from 'chai';
import { setupRenderingTest } from 'ember-mocha';
const {Errors} = DS;
// 从Ember Data中获取私有Errors类用于模拟验证错误
const { Errors } = DS;
// 配置服务桩提供博客URL
let configStub = Service.extend({
blogUrl: 'http://localhost:2368'
});
// 媒体查询服务桩:控制移动端视图模拟
let mediaQueriesStub = Service.extend({
maxWidth600: false
maxWidth600: false // 默认模拟非移动端
});
// 描述"标签表单组件Tags::TagForm"的集成测试套件(当前跳过测试)
describe.skip('Integration: Component: tags/tag-form', function () {
// 设置渲染测试环境
setupRenderingTest();
// 每个测试用例执行前的准备工作
beforeEach(function () {
/* eslint-disable camelcase */
// 创建模拟标签对象,包含基础属性和验证相关字段
let tag = EmberObject.create({
id: 1,
name: 'Test',
@ -31,38 +38,48 @@ describe.skip('Integration: Component: tags/tag-form', function () {
description: 'Description.',
metaTitle: 'Meta Title',
metaDescription: 'Meta description',
errors: Errors.create(),
hasValidated: []
errors: Errors.create(), // 用于存储验证错误
hasValidated: [] // 用于记录已验证的字段
});
/* eslint-enable camelcase */
// 将标签对象和属性设置方法存入测试上下文
this.set('tag', tag);
this.set('setProperty', function (property, value) {
// this should be overridden if a call is expected
// 若未被覆盖,调用时会打印错误(用于捕获意外调用)
// eslint-disable-next-line no-console
console.error(`setProperty called '${property}: ${value}'`);
});
// 注册服务桩,替换真实服务
this.owner.register('service:config', configStub);
this.owner.register('service:media-queries', mediaQueriesStub);
});
// 测试用例:表单标题显示正确
it('has the correct title', async function () {
// 渲染标签表单组件
await render(hbs`
<Tags::TagForm @tag={{this.tag}} @setProperty={{this.setProperty}} />
`);
// 断言:现有标签的标题为"Tag settings"
expect(find('.tag-settings-pane h4').textContent, 'existing tag title').to.equal('Tag settings');
// 切换为新标签设置isNew属性
this.set('tag.isNew', true);
// 断言:新标签的标题为"New tag"
expect(find('.tag-settings-pane h4').textContent, 'new tag title').to.equal('New tag');
});
// 测试用例:正确渲染主要设置项
it('renders main settings', async function () {
await render(hbs`
<Tags::TagForm @tag={{this.tag}} @setProperty={{this.setProperty}} />
`);
// 断言:显示图片上传器
expect(findAll('.gh-image-uploader').length, 'displays image uploader').to.equal(1);
// 断言:各字段值正确显示
expect(find('input[name="name"]').value, 'name field value').to.equal('Test');
expect(find('input[name="slug"]').value, 'slug field value').to.equal('test');
expect(find('textarea[name="description"]').value, 'description field value').to.equal('Description.');
@ -70,40 +87,48 @@ describe.skip('Integration: Component: tags/tag-form', function () {
expect(find('textarea[name="metaDescription"]').value, 'metaDescription field value').to.equal('Meta description');
});
// 测试用例:可在主要设置和元数据设置之间切换
it('can switch between main/meta settings', async function () {
await render(hbs`
<Tags::TagForm @tag={{this.tag}} @setProperty={{this.setProperty}} />
`);
// 断言:初始状态显示主要设置,隐藏元数据设置
expect(find('.tag-settings-pane').classList.contains('settings-menu-pane-in'), 'main settings are displayed by default').to.be.true;
expect(find('.tag-meta-settings-pane').classList.contains('settings-menu-pane-out-right'), 'meta settings are hidden by default').to.be.true;
// 点击"Meta Data"按钮切换到元数据设置
await click('.meta-data-button');
// 断言:切换后隐藏主要设置,显示元数据设置
expect(find('.tag-settings-pane').classList.contains('settings-menu-pane-out-left'), 'main settings are hidden after clicking Meta Data button').to.be.true;
expect(find('.tag-meta-settings-pane').classList.contains('settings-menu-pane-in'), 'meta settings are displayed after clicking Meta Data button').to.be.true;
// 点击"back"按钮返回主要设置
await click('.back');
// 断言:返回后显示主要设置,隐藏元数据设置
expect(find('.tag-settings-pane').classList.contains('settings-menu-pane-in'), 'main settings are displayed after clicking "back"').to.be.true;
expect(find('.tag-meta-settings-pane').classList.contains('settings-menu-pane-out-right'), 'meta settings are hidden after clicking "back"').to.be.true;
});
// 测试用例:属性采用单向绑定(输入框值变化不直接修改源数据)
it('has one-way binding for properties', async function () {
this.set('setProperty', function () {
// noop
});
// 覆盖setProperty为无操作避免干扰测试
this.set('setProperty', function () {});
await render(hbs`
<Tags::TagForm @tag={{this.tag}} @setProperty={{this.setProperty}} />
`);
// 修改各输入框的值
await fillIn('input[name="name"]', 'New name');
await fillIn('input[name="slug"]', 'new-slug');
await fillIn('textarea[name="description"]', 'New description');
await fillIn('input[name="metaTitle"]', 'New metaTitle');
await fillIn('textarea[name="metaDescription"]', 'New metaDescription');
// 断言:源标签对象的属性未被修改(单向绑定生效)
expect(this.get('tag.name'), 'tag name').to.equal('Test');
expect(this.get('tag.slug'), 'tag slug').to.equal('test');
expect(this.get('tag.description'), 'tag description').to.equal('Description.');
@ -111,19 +136,23 @@ describe.skip('Integration: Component: tags/tag-form', function () {
expect(this.get('tag.metaDescription'), 'tag metaDescription').to.equal('Meta description');
});
// 测试用例所有字段失焦时触发setProperty动作
it('triggers setProperty action on blur of all fields', async function () {
let lastSeenProperty = '';
let lastSeenValue = '';
// 覆盖setProperty记录最后一次调用的属性和值
this.set('setProperty', function (property, value) {
lastSeenProperty = property;
lastSeenValue = value;
});
// 辅助函数测试字段失焦时是否正确触发setProperty
let testSetProperty = async (selector, expectedProperty, expectedValue) => {
await click(selector);
await fillIn(selector, expectedValue);
await blur(selector);
await click(selector); // 聚焦字段
await fillIn(selector, expectedValue); // 输入值
await blur(selector); // 失焦
// 断言:触发的属性和值正确
expect(lastSeenProperty, 'property').to.equal(expectedProperty);
expect(lastSeenValue, 'value').to.equal(expectedValue);
};
@ -132,6 +161,7 @@ describe.skip('Integration: Component: tags/tag-form', function () {
<Tags::TagForm @tag={{this.tag}} @setProperty={{this.setProperty}} />
`);
// 测试所有字段
await testSetProperty('input[name="name"]', 'name', 'New name');
await testSetProperty('input[name="slug"]', 'slug', 'new-slug');
await testSetProperty('textarea[name="description"]', 'description', 'New description');
@ -139,10 +169,12 @@ describe.skip('Integration: Component: tags/tag-form', function () {
await testSetProperty('textarea[name="metaDescription"]', 'metaDescription', 'New metaDescription');
});
// 测试用例:显示已验证字段的错误信息
it('displays error messages for validated fields', async function () {
let errors = this.get('tag.errors');
let hasValidated = this.get('tag.hasValidated');
// 为各字段添加验证错误并标记为已验证
errors.add('name', 'must be present');
hasValidated.push('name');
@ -162,91 +194,121 @@ describe.skip('Integration: Component: tags/tag-form', function () {
<Tags::TagForm @tag={{this.tag}} @setProperty={{this.setProperty}} />
`);
// 验证name字段错误状态
let nameFormGroup = find('input[name="name"]').closest('.form-group');
expect(nameFormGroup, 'name form group has error state').to.have.class('error');
expect(nameFormGroup.querySelector('.response'), 'name form group has error message').to.exist;
// 验证slug字段错误状态
let slugFormGroup = find('input[name="slug"]').closest('.form-group');
expect(slugFormGroup, 'slug form group has error state').to.have.class('error');
expect(slugFormGroup.querySelector('.response'), 'slug form group has error message').to.exist;
// 验证description字段错误状态
let descriptionFormGroup = find('textarea[name="description"]').closest('.form-group');
expect(descriptionFormGroup, 'description form group has error state').to.have.class('error');
// 验证metaTitle字段错误状态
let metaTitleFormGroup = find('input[name="metaTitle"]').closest('.form-group');
expect(metaTitleFormGroup, 'metaTitle form group has error state').to.have.class('error');
expect(metaTitleFormGroup.querySelector('.response'), 'metaTitle form group has error message').to.exist;
// 验证metaDescription字段错误状态
let metaDescriptionFormGroup = find('textarea[name="metaDescription"]').closest('.form-group');
expect(metaDescriptionFormGroup, 'metaDescription form group has error state').to.have.class('error');
expect(metaDescriptionFormGroup.querySelector('.response'), 'metaDescription form group has error message').to.exist;
});
// 测试用例:显示文本字段的字符计数
it('displays char count for text fields', async function () {
await render(hbs`
<Tags::TagForm @tag={{this.tag}} @setProperty={{this.setProperty}} />
`);
// 验证描述字段的字符计数("Description." 共12个字符
let descriptionFormGroup = find('textarea[name="description"]').closest('.form-group');
expect(descriptionFormGroup.querySelector('.word-count'), 'description char count').to.have.trimmed.text('12');
// 验证元描述字段的字符计数("Meta description" 共16个字符
let metaDescriptionFormGroup = find('textarea[name="metaDescription"]').closest('.form-group');
expect(metaDescriptionFormGroup.querySelector('.word-count'), 'description char count').to.have.trimmed.text('16');
});
// 测试用例正确渲染SEO标题预览
it('renders SEO title preview', async function () {
await render(hbs`
<Tags::TagForm @tag={{this.tag}} @setProperty={{this.setProperty}} />
`);
// 断言存在metaTitle时显示metaTitle
expect(find('.seo-preview-title').textContent, 'displays meta title if present').to.equal('Meta Title');
// 移除metaTitle
this.set('tag.metaTitle', '');
// 断言无metaTitle时回退到标签名称
expect(find('.seo-preview-title').textContent, 'falls back to tag name without metaTitle').to.equal('Test');
// 设置超长名称150个x
this.set('tag.name', (new Array(151).join('x')));
// 断言标题被截断为70字符+省略号
let expectedLength = 70 + '…'.length;
expect(find('.seo-preview-title').textContent.length, 'cuts title to max 70 chars').to.equal(expectedLength);
});
// 测试用例正确渲染SEO URL预览
it('renders SEO URL preview', async function () {
await render(hbs`
<Tags::TagForm @tag={{this.tag}} @setProperty={{this.setProperty}} />
`);
// 断言URL预览包含博客地址、标签路径和slug
expect(find('.seo-preview-link').textContent, 'adds url and tag prefix').to.equal('http://localhost:2368/tag/test/');
// 设置超长slug150个x
this.set('tag.slug', (new Array(151).join('x')));
// 断言slug被截断为70字符+省略号
let expectedLength = 70 + '…'.length;
expect(find('.seo-preview-link').textContent.length, 'cuts slug to max 70 chars').to.equal(expectedLength);
});
// 测试用例正确渲染SEO描述预览
it('renders SEO description preview', async function () {
await render(hbs`
<Tags::TagForm @tag={{this.tag}} @setProperty={{this.setProperty}} />
`);
// 断言存在metaDescription时显示metaDescription
expect(find('.seo-preview-description').textContent, 'displays meta description if present').to.equal('Meta description');
// 移除metaDescription
this.set('tag.metaDescription', '');
// 断言无metaDescription时回退到描述
expect(find('.seo-preview-description').textContent, 'falls back to tag description without metaDescription').to.equal('Description.');
// 设置超长描述499个x
this.set('tag.description', (new Array(500).join('x')));
// 断言描述被截断为156字符+省略号
let expectedLength = 156 + '…'.length;
expect(find('.seo-preview-description').textContent.length, 'cuts description to max 156 chars').to.equal(expectedLength);
});
// 测试用例:接收新标签时重置表单状态
it('resets if a new tag is received', async function () {
await render(hbs`
<Tags::TagForm @tag={{this.tag}} @setProperty={{this.setProperty}} />
`);
// 切换到元数据设置面板
await click('.meta-data-button');
expect(find('.tag-meta-settings-pane').classList.contains('settings-menu-pane-in'), 'meta data pane is shown').to.be.true;
this.set('tag', EmberObject.create({id: '2'}));
// 设置新的标签对象
this.set('tag', EmberObject.create({ id: '2' }));
// 断言:表单重置为显示主要设置
expect(find('.tag-settings-pane').classList.contains('settings-menu-pane-in'), 'resets to main settings').to.be.true;
});
// 测试用例:点击删除按钮时触发删除模态框
it('triggers delete tag modal on delete click', async function () {
let openModalFired = false;
// 覆盖openModal方法标记为已触发
this.set('openModal', () => {
openModalFired = true;
});
@ -254,12 +316,16 @@ describe.skip('Integration: Component: tags/tag-form', function () {
await render(hbs`
<Tags::TagForm @tag={{this.tag}} @setProperty={{this.setProperty}} @showDeleteTagModal={{this.openModal}} />
`);
// 点击删除按钮
await click('.settings-menu-delete-button');
// 断言:删除模态框触发方法被调用
expect(openModalFired).to.be.true;
});
// 测试用例:移动端显示标签返回箭头链接
it('shows tags arrow link on mobile', async function () {
// 获取媒体查询服务并设置为移动端宽度≤600px
let mediaQueries = this.owner.lookup('service:media-queries');
mediaQueries.set('maxWidth600', true);
@ -267,6 +333,7 @@ describe.skip('Integration: Component: tags/tag-form', function () {
<Tags::TagForm @tag={{this.tag}} @setProperty={{this.setProperty}} />
`);
// 断言:移动端显示标签返回链接
expect(findAll('.tag-settings-pane .settings-menu-header .settings-menu-header-action').length, 'tags link is shown').to.equal(1);
});
});
});

@ -1,71 +1,111 @@
import {describe, it} from 'mocha';
import {expect} from 'chai';
import {setupMirage} from 'ember-cli-mirage/test-support';
import {setupTest} from 'ember-mocha';
import { describe, it } from 'mocha';
import { expect } from 'chai';
import { setupMirage } from 'ember-cli-mirage/test-support';
import { setupTest } from 'ember-mocha';
/**
* 标签模型Model: tag的集成测试
* 验证标签模型在各种操作下对搜索内容过期状态的影响
*/
describe('Integration: Model: tag', function () {
// 设置测试环境和Mirage模拟服务器
const hooks = setupTest();
setupMirage(hooks);
// 声明数据存储服务变量
let store;
// 每个测试用例执行前的准备工作
beforeEach(function () {
// 获取Ember的数据存储服务store
store = this.owner.lookup('service:store');
});
// 描述"搜索过期search expiry"的测试场景
describe('search expiry', function () {
// 声明搜索服务变量
let search;
// 每个子测试用例执行前的准备工作
beforeEach(function () {
// 获取搜索服务
search = this.owner.lookup('service:search');
// 初始设置搜索内容为未过期状态
search.isContentStale = false;
});
// 测试用例:创建标签时使搜索内容过期
it('expires on create', async function () {
// 创建新标签记录
const tagModel = await store.createRecord('tag');
tagModel.name = 'Test tag';
// 保存标签到服务器
await tagModel.save();
// 断言搜索内容过期标志为true创建操作触发过期
expect(search.isContentStale, 'stale flag after save').to.be.true;
});
// 测试用例:删除标签时使搜索内容过期
it('expires on delete', async function () {
// 在服务器端创建标签
const serverTag = this.server.create('tag');
// 从数据存储中获取该标签
const tagModel = await store.find('tag', serverTag.id);
// 删除标签
await tagModel.destroyRecord();
// 断言搜索内容过期标志为true删除操作触发过期
expect(search.isContentStale, 'stale flag after delete').to.be.true;
});
// 测试用例:修改标签名称时使搜索内容过期
it('expires when name changed', async function () {
// 在服务器端创建标签
const serverTag = this.server.create('tag');
// 从数据存储中获取该标签
const tagModel = await store.find('tag', serverTag.id);
// 修改标签名称
tagModel.name = 'New name';
// 保存修改
await tagModel.save();
// 断言搜索内容过期标志为true名称修改触发过期
expect(search.isContentStale, 'stale flag after save').to.be.true;
});
// 测试用例修改标签URLslug时使搜索内容过期
it('expires when url changed', async function () {
// 在服务器端创建标签
const serverTag = this.server.create('tag');
// 从数据存储中获取该标签
const tagModel = await store.find('tag', serverTag.id);
// 修改标签slugURL的一部分
tagModel.slug = 'new-slug';
// 保存修改
await tagModel.save();
// 断言搜索内容过期标志为trueURL修改触发过期
expect(search.isContentStale, 'stale flag after save').to.be.true;
});
// 测试用例:修改非名称字段时不使搜索内容过期
it('does not expire on non-name change', async function () {
// 在服务器端创建标签
const serverTag = this.server.create('tag');
// 从数据存储中获取该标签
const tagModel = await store.find('tag', serverTag.id);
// 修改标签描述(非名称相关字段)
tagModel.description = 'New description';
// 保存修改
await tagModel.save();
// 断言搜索内容过期标志为false非名称修改不触发过期
expect(search.isContentStale, 'stale flag after save').to.be.false;
});
});
});
});

@ -1,13 +1,24 @@
import {describe, it} from 'mocha';
import {expect} from 'chai';
import {setupTest} from 'ember-mocha';
import { describe, it } from 'mocha';
import { expect } from 'chai';
import { setupTest } from 'ember-mocha';
/**
* 标签模型Model: tag的单元测试
* 验证模型的基础属性和行为
*/
describe('Unit: Model: tag', function () {
// 设置单元测试环境
setupTest();
/**
* 测试用例标签模型的验证类型validationType"tag"
* 验证模型在验证逻辑中使用正确的类型标识
*/
it('has a validation type of "tag"', function () {
// 从数据存储服务创建一个标签模型实例
let model = this.owner.lookup('service:store').createRecord('tag');
// 断言模型的validationType属性值为"tag"
expect(model.get('validationType')).to.equal('tag');
});
});
});

@ -1,50 +1,106 @@
// # Tags Helper
// Usage: `{{tags}}`, `{{tags separator=' - '}}`
// 用法: `{{tags}}`, `{{tags separator=' - '}}`
//
// Returns a string of the tags on the post.
// By default, tags are separated by commas.
// 返回文章标签的字符串形式
// 默认情况下,标签之间用逗号分隔
//
// Note that the standard {{#each tags}} implementation is unaffected by this helper
const {urlService} = require('../services/proxy');
const {SafeString, escapeExpression, templates} = require('../services/handlebars');
// 中文说明:
// - 该 helper 在主题模板中用于渲染单个文章/页面的标签列表。
// - 支持的参数说明:
// - autolink (boolean|string): 是否将标签渲染为链接(默认 true。传入字符串 'false' 可关闭。
// - separator (string): 标签之间的分隔符,默认使用 ", ".
// - prefix / suffix (string): 输出的前缀与后缀,默认空字符串。
// - limit / from / to (number): 用于对标签进行切片(注意 from 是 1-based 索引);
// 如果同时指定 limit 与 from会使用 limit+from 计算默认的 to 值。
// - visibility (string): 通过 helpers 的 visibility 工具进行过滤public/internal
//
// 实现细节:
// - 先通过 visibility.filter 对传入的 tags 进行可见性过滤并对每一项应用 processTag
// processTag 根据 autolink 决定生成带 <a> 链接的 HTML 或纯文本(已转义)。
// - 对结果数组应用 from/to/limit 的裁切逻辑(将 from 转为 0-based然后用 separator 拼接。
// - 最终使用 SafeString 返回,保证已生成的 HTML例如标签链接不会被 Handlebars 再次转义。
//
// 注意:此 helper 仅负责渲染表现层,标签的 URL 生成依赖于 `urlService.getUrlByResourceId`
// 如果修改了 URL 规则或可见性规则,应同步更新此 helper 的实现。
const { urlService } = require('../services/proxy');
const { SafeString, escapeExpression, templates } = require('../services/handlebars');
const isString = require('lodash/isString');
const ghostHelperUtils = require('@tryghost/helpers').utils;
module.exports = function tags(options) {
// 处理可选参数,确保参数对象存在
options = options || {};
options.hash = options.hash || {};
// 解析参数:自动链接(默认开启)
// 如果autolink参数是字符串且值为'false',则关闭自动链接
const autolink = !(isString(options.hash.autolink) && options.hash.autolink === 'false');
// 解析参数:分隔符(默认逗号加空格)
const separator = isString(options.hash.separator) ? options.hash.separator : ', ';
// 解析参数:前缀(默认空)
const prefix = isString(options.hash.prefix) ? options.hash.prefix : '';
// 解析参数:后缀(默认空)
const suffix = isString(options.hash.suffix) ? options.hash.suffix : '';
// 解析参数:限制数量(默认无限制)
const limit = options.hash.limit ? parseInt(options.hash.limit, 10) : undefined;
// 初始化输出字符串
let output = '';
// 解析参数起始位置默认11-based索引
let from = options.hash.from ? parseInt(options.hash.from, 10) : 1;
// 解析参数:结束位置(默认无)
let to = options.hash.to ? parseInt(options.hash.to, 10) : undefined;
/**
* 创建标签列表数组
* @param {Array} tagsList - 标签数组
* @returns {Array} 处理后的标签字符串数组
*/
function createTagList(tagsList) {
/**
* 处理单个标签
* @param {Object} tag - 标签对象
* @returns {string} 处理后的标签字符串带链接或纯文本
*/
function processTag(tag) {
// 如果开启自动链接,返回带链接的标签;否则返回纯文本标签
return autolink ? templates.link({
url: urlService.getUrlByResourceId(tag.id, {withSubdirectory: true}),
text: escapeExpression(tag.name)
url: urlService.getUrlByResourceId(tag.id, { withSubdirectory: true }), // 获取标签的URL
text: escapeExpression(tag.name) // 转义标签名称防止XSS
}) : escapeExpression(tag.name);
}
// 根据可见性筛选标签并应用processTag处理
return ghostHelperUtils.visibility.filter(tagsList, options.hash.visibility, processTag);
}
// 如果存在标签且标签数组不为空
if (this.tags && this.tags.length) {
// 生成处理后的标签列表数组
output = createTagList(this.tags);
from -= 1; // From uses 1-indexed, but array uses 0-indexed.
// 转换起始位置为0-based索引因为from参数是1-based
from -= 1;
// 计算结束位置如果指定了to则用to否则用limit+from否则用数组长度
to = to || limit + from || output.length;
// 截取指定范围的标签,并使用分隔符拼接
output = output.slice(from, to).join(separator);
}
// 如果有输出内容,添加前缀和后缀
if (output) {
output = prefix + output + suffix;
}
// 返回安全字符串防止Handlebars自动转义HTML
return new SafeString(output);
};
};

@ -1,10 +1,36 @@
/*
* Public Tags API controller
*
* Responsibilities:
* - Provide public endpoints for browsing and reading tags
* - Use models.TagPublic for public-safe queries/serialization
* - Validate allowed include parameters (e.g. count.posts)
* - Integrate cache provided by tagsPublicService when available
*
* Note: this controller is intentionally separate from the admin
* endpoints to ensure public/unauthenticated access uses the
* appropriate models/serialization and caching layers.
*
* 中文说明
* 本文件为公开无需管理员权限标签相关 API 的控制器实现
* - browse / read 两个方法暴露给前端或第三方在公共场景下读取标签数据
* - 为了保证公开接口的安全性与性能使用 `models.TagPublic`只包含可公开的字段与序列化规则
* - 在允许的 include 参数上会进行验证目前仅允许 `count.posts`以避免不安全或昂贵的嵌入查询
* - 若存在 `tagsPublicService.api.cache`browse 方法会使用它来启用缓存以提高性能 cacheInvalidate header 默认为 false控制器本身不负责主动失效缓存
*
* 注意事项
* - 管理端admin和公开端分离是刻意的设计避免权限混淆与敏感字段外泄
* - 若修改了可公开的字段集合或序列化逻辑请同时更新对应的 models 与本处允许的 include 列表
*/
const tpl = require('@tryghost/tpl');
const errors = require('@tryghost/errors');
const models = require('../../models');
const tagsPublicService = require('../../services/tags-public');
// 允许通过 include 参数包含的关联计数(仅允许对外公开的项)
const ALLOWED_INCLUDES = ['count.posts'];
// 本地化/模板消息(可以用于抛出用户友好的错误信息)
const messages = {
tagNotFound: 'Tag not found.'
};

@ -1,3 +1,24 @@
/*
* Admin Tags API controller
*
* Responsibilities:
* - Provide admin endpoints for managing tags (browse/read/add/edit/destroy)
* - Enforce permission checks for admin operations
* - Validate allowed include parameters (e.g. count.posts)
* - Delegate core data operations to models.Tag
* - Trigger cache invalidation headers on changes
*/
/*
* 中文说明
* 本文件为管理端admin标签相关 API 控制器提供对标签的增删改查接口
* - browse / read: 管理后台读取标签列表或单一标签可包含 count.posts 等允许的 include
* - add / edit / destroy: 管理员权限下的写操作会调用 `models.Tag` 执行数据变更
*
* 关键注意事项
* - 写操作会在必要时设置 `X-Cache-Invalidate` 以通知 CDN/缓存层清理缓存read/browse 默认不主动失效缓存
* - 本控制器启用了权限检查permissions: true只有具备管理员权限的请求才能执行写操作
* - 如果修改了可包含的 include 列表或序列化规则请同步更新 `ALLOWED_INCLUDES` 常量与模型/序列化逻辑
*/
const tpl = require('@tryghost/tpl');
const errors = require('@tryghost/errors');
const models = require('../../models');

@ -1,3 +1,30 @@
/*
* Tag model
*
* Responsibilities:
* - Represent the 'tags' table via Bookshelf/ghostBookshelf
* - Handle slug generation and normalization on save
* - Provide relations to posts (belongsToMany)
* - Convert transform-ready urls to absolute on parse, and prepare urls on write
* - Emit tag.* events on create/update/delete for webhooks and internals
* - Provide a custom destroy which detaches post relations before deleting
*
* Notes:
* - Many services call into models.Tag (e.g. PostsService) to create or link tags.
* - countRelations defines how to include posts counts when requested via include=count.posts
*/
/*
* 中文说明
* 本模型封装了 tags 表的数据库映射与行为提供给服务层与 API 层使用
* - 在保存时处理 slug 的生成与规范化onSaving并根据 name 前缀处理 visibility例如以 `#` 开头视为内部标签
* - URL 字段在读取/写入时进行转换parse / formatOnWrite以便模板/序列化使用绝对/transform-ready URL
* - 定义与 posts 的多对多关系posts并在删除标签前负责解除与所有帖子的关联自定义 destroy 实现以防止孤立的关联数据
* - 触发生命周期事件tag.added / tag.edited / tag.deleted webhook事件系统或其他内部服务订阅
*
* 注意
* - 许多业务逻辑例如 PostsService会调用此模型创建或关联标签修改模型的行为可能影响这些调用点
* - 当需要在公开 API 中返回统计信息include=count.postscountRelations 提供了构造查询的方法注意在公共上下文中对帖子的过滤published,type=post
*/
const ghostBookshelf = require('./base');
const tpl = require('@tryghost/tpl');
const errors = require('@tryghost/errors');
@ -98,6 +125,11 @@ Tag = ghostBookshelf.Model.extend({
model.emitChange('deleted', options);
},
// onSaving hook runs before persisting a tag.
// Responsibilities:
// - Ensure a name exists when only slug is supplied (e.g. nested creation)
// - Detect internal tags (name starts with '#') and set visibility
// - Generate a unique slug when necessary using the shared generator
onSaving: function onSaving(newTag, attr, options) {
const self = this;
@ -126,10 +158,12 @@ Tag = ghostBookshelf.Model.extend({
}
},
// Relationship: tags <-> posts (many-to-many)
posts: function posts() {
return this.belongsToMany('Post');
},
// toJSON: normalize attributes when serializing model instances
toJSON: function toJSON(unfilteredOptions) {
const attrs = ghostBookshelf.Model.prototype.toJSON.call(this, unfilteredOptions);
@ -141,6 +175,7 @@ Tag = ghostBookshelf.Model.extend({
},
defaultColumnsToFetch() {
// By default, fetching minimal columns for lightweight queries
return ['id'];
}
}, {
@ -166,6 +201,7 @@ Tag = ghostBookshelf.Model.extend({
return options;
},
// Configure how to compute relation counts when include=count.posts is requested
countRelations() {
return {
posts(modelOrCollection, options) {
@ -186,6 +222,11 @@ Tag = ghostBookshelf.Model.extend({
};
},
// Custom destroy implementation which:
// - Fetches the tag with related posts
// - Detaches all posts from the tag (posts_tags entries)
// - Deletes the tag row
// This prevents orphaned relations and mirrors expected semantics when deleting a tag.
destroy: function destroy(unfilteredOptions) {
const options = this.filterOptions(unfilteredOptions, 'destroy', {extraAllowedProperties: ['id']});
options.withRelated = ['posts'];

@ -16,6 +16,21 @@ const messages = {
postNotFound: 'Post not found.'
};
/*
* PostsService
*
* 中文说明
* - PostsService 负责与 posts 资源相关的业务逻辑和操作浏览读取编辑批量操作导出等
* - 与标签Tag相关的交互点主要集中在批量添加标签#bulkAddTags复制文章时复制标签引用
* copyPost 中的 tags 字段以及在批量销毁/编辑时需清理或维护 posts_tags 联合表
* - 对标签的批量添加操作会
* 1. 在事务中为不存在的标签调用 models.Tag.add 创建新标签保证原子性
* 2. 查询符合 filter 的文章 id 列表
* 3. 构建 posts_tags 的插入数据使用 ObjectId 生成关联记录的 id并写入数据库
* 4. 调用 Post.addActions('edited', ...) 来记录编辑动作并触发相应的事件
* - 在修改这类逻辑时需要注意事务transacting和并发一致性以及 posts_tags 表的去重/排序逻辑
*/
class PostsService {
constructor({urlUtils, models, isSet, stats, emailService, postsExporter}) {
this.urlUtils = urlUtils;

@ -9,52 +9,70 @@ const testUtils = require('../../utils');
const dbUtils = require('../../utils/db-utils');
const localUtils = require('./utils');
// 标签内容API测试套件验证标签相关API的功能和响应格式
describe('Tags Content API', function () {
let request;
let request; // 用于发送HTTP请求的supertest代理
// 测试前的初始化工作
before(async function () {
await localUtils.startGhost();
await localUtils.startGhost(); // 启动Ghost服务
// 创建指向Ghost服务URL的请求代理
request = supertest.agent(config.get('url'));
// 初始化测试数据用户、文章、标签、API密钥等
await testUtils.initFixtures('users', 'user:inactive', 'posts', 'tags:extra', 'api_keys');
});
// 每个测试用例执行后恢复配置
afterEach(async function () {
await configUtils.restore();
});
// 获取有效的API访问密钥用于鉴权
const validKey = localUtils.getValidKey();
// 测试用例:能够请求标签列表
it('Can request tags', async function () {
// 发送GET请求获取标签列表附带API密钥
const res = await request.get(localUtils.API.getApiQuery(`tags/?key=${validKey}`))
.set('Origin', testUtils.API.getURL())
.expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.public)
.expect(200);
.set('Origin', testUtils.API.getURL()) // 设置请求源
.expect('Content-Type', /json/) // 验证响应为JSON格式
.expect('Cache-Control', testUtils.cacheRules.public) // 验证缓存控制头
.expect(200); // 验证HTTP状态码为200
// 验证缓存失效头不存在
should.not.exist(res.headers['x-cache-invalidate']);
const jsonResponse = res.body;
// 验证响应结构包含tags数组
should.exist(jsonResponse.tags);
// 检查响应顶层结构是否符合API规范
localUtils.API.checkResponse(jsonResponse, 'tags');
// 验证返回4个标签与测试数据一致
jsonResponse.tags.should.have.length(4);
// 检查单个标签的响应结构包含url字段
localUtils.API.checkResponse(jsonResponse.tags[0], 'tag', ['url']);
// 检查分页元数据结构是否符合规范
localUtils.API.checkResponse(jsonResponse.meta.pagination, 'pagination');
// Default order 'name asc' check
// the ordering difference is described in https://github.com/TryGhost/Ghost/issues/6104
// this condition should be removed once issue mentioned above ^ is resolved
// 验证默认排序(按名称升序)
// 排序差异说明:https://github.com/TryGhost/Ghost/issues/6104
// 上述问题解决后可移除数据库类型判断
if (dbUtils.isMySQL()) {
// MySQL环境下的排序结果
jsonResponse.tags[0].name.should.eql('bacon');
jsonResponse.tags[3].name.should.eql('kitchen sink');
} else {
// 非MySQL环境下的排序结果
jsonResponse.tags[0].name.should.eql('Getting Started');
jsonResponse.tags[3].name.should.eql('kitchen sink');
}
// 验证标签URL的有效性包含协议和主机
should.exist(res.body.tags[0].url);
should.exist(url.parse(res.body.tags[0].url).protocol);
should.exist(url.parse(res.body.tags[0].url).host);
});
// 测试用例能够使用limit=all请求所有标签
it('Can request tags with limit=all', async function () {
const res = await request.get(localUtils.API.getApiQuery(`tags/?limit=all&key=${validKey}`))
.set('Origin', testUtils.API.getURL())
@ -66,11 +84,13 @@ describe('Tags Content API', function () {
const jsonResponse = res.body;
should.exist(jsonResponse.tags);
localUtils.API.checkResponse(jsonResponse, 'tags');
// 验证返回所有4个标签limit=all生效
jsonResponse.tags.should.have.length(4);
localUtils.API.checkResponse(jsonResponse.tags[0], 'tag', ['url']);
localUtils.API.checkResponse(jsonResponse.meta.pagination, 'pagination');
});
// 测试用例:能够限制返回的标签数量
it('Can limit tags to receive', async function () {
const res = await request.get(localUtils.API.getApiQuery(`tags/?limit=3&key=${validKey}`))
.set('Origin', testUtils.API.getURL())
@ -82,11 +102,13 @@ describe('Tags Content API', function () {
const jsonResponse = res.body;
should.exist(jsonResponse.tags);
localUtils.API.checkResponse(jsonResponse, 'tags');
// 验证只返回3个标签limit=3生效
jsonResponse.tags.should.have.length(3);
localUtils.API.checkResponse(jsonResponse.tags[0], 'tag', ['url']);
localUtils.API.checkResponse(jsonResponse.meta.pagination, 'pagination');
});
// 测试用例:能够包含标签关联的文章计数
it('Can include post count', async function () {
const res = await request.get(localUtils.API.getApiQuery(`tags/?key=${validKey}&include=count.posts`))
.set('Origin', testUtils.API.getURL())
@ -99,13 +121,14 @@ describe('Tags Content API', function () {
should.exist(jsonResponse.tags);
jsonResponse.tags.should.be.an.Array().with.lengthOf(4);
// Each tag should have the correct count
// 验证每个标签的文章计数正确(与测试数据一致)
_.find(jsonResponse.tags, {name: 'Getting Started'}).count.posts.should.eql(7);
_.find(jsonResponse.tags, {name: 'kitchen sink'}).count.posts.should.eql(2);
_.find(jsonResponse.tags, {name: 'bacon'}).count.posts.should.eql(2);
_.find(jsonResponse.tags, {name: 'chorizo'}).count.posts.should.eql(1);
});
// 测试用例能够筛选多个字段并验证url字段有效性
it('Can use multiple fields and have valid url fields', async function () {
const res = await request.get(localUtils.API.getApiQuery(`tags/?key=${validKey}&fields=url,name`))
.set('Origin', testUtils.API.getURL())
@ -117,14 +140,17 @@ describe('Tags Content API', function () {
assert(jsonResponse.tags);
// 辅助函数:通过名称查找标签
const getTag = name => jsonResponse.tags.find(tag => tag.name === name);
// 验证每个标签的URL以预期路径结尾
assert(getTag('Getting Started').url.endsWith('/tag/getting-started/'));
assert(getTag('kitchen sink').url.endsWith('/tag/kitchen-sink/'));
assert(getTag('bacon').url.endsWith('/tag/bacon/'));
assert(getTag('chorizo').url.endsWith('/tag/chorizo/'));
});
// 测试用例能够只返回url字段并验证其有效性
it('Can use single url field and have valid url fields', async function () {
const res = await request.get(localUtils.API.getApiQuery(`tags/?key=${validKey}&fields=url`))
.set('Origin', testUtils.API.getURL())
@ -136,11 +162,13 @@ describe('Tags Content API', function () {
assert(jsonResponse.tags);
// 辅助函数通过URL路径查找标签
const getTag = path => jsonResponse.tags.find(tag => tag.url.endsWith(path));
// 验证所有预期标签的URL存在
assert(getTag('/tag/getting-started/'));
assert(getTag('/tag/kitchen-sink/'));
assert(getTag('/tag/bacon/'));
assert(getTag('/tag/chorizo/'));
});
});
});

@ -1,39 +1,55 @@
const {agentProvider, mockManager, fixtureManager, matchers} = require('../utils/e2e-framework');
const {anyGhostAgent, anyObjectId, anyISODateTime, anyString, anyContentVersion, anyNumber, anyLocalURL} = matchers;
const { agentProvider, mockManager, fixtureManager, matchers } = require('../utils/e2e-framework');
// 导入匹配器用于验证响应中的动态值如ID、时间戳等
const { anyGhostAgent, anyObjectId, anyISODateTime, anyString, anyContentVersion, anyNumber, anyLocalURL } = matchers;
// 标签快照定义标签对象的基础结构用于验证webhook响应中的标签数据
const tagSnapshot = {
created_at: anyISODateTime,
description: anyString,
id: anyObjectId,
updated_at: anyISODateTime
created_at: anyISODateTime, // 匹配任意ISO格式的创建时间
description: anyString, // 匹配任意字符串格式的描述
id: anyObjectId, // 匹配任意有效的对象ID
updated_at: anyISODateTime // 匹配任意ISO格式的更新时间
};
// 描述"tag.* 事件"的端到端测试套件
describe('tag.* events', function () {
let adminAPIAgent;
let webhookMockReceiver;
let adminAPIAgent; // 管理员API代理用于发送管理员权限的请求
let webhookMockReceiver; // webhook模拟接收器用于捕获和验证webhook请求
// 测试套件启动前的准备工作
before(async function () {
// 获取管理员API代理
adminAPIAgent = await agentProvider.getAdminAPIAgent();
// 初始化集成测试数据用于webhook
await fixtureManager.init('integrations');
// 以管理员身份登录
await adminAPIAgent.loginAsOwner();
});
// 每个测试用例执行前的准备工作
beforeEach(function () {
// 创建新的webhook模拟接收器
webhookMockReceiver = mockManager.mockWebhookRequests();
});
// 每个测试用例执行后的清理工作
afterEach(function () {
// 恢复所有模拟
mockManager.restore();
});
// 测试用例tag.added事件被正确触发
it('tag.added event is triggered', async function () {
// 定义webhook接收URL
const webhookURL = 'https://test-webhook-receiver.com/tag-added/';
// 模拟该URL的webhook接收
await webhookMockReceiver.mock(webhookURL);
// 插入一个监听"tag.added"事件的webhook
await fixtureManager.insertWebhook({
event: 'tag.added',
url: webhookURL
});
// 创建一个新标签
await adminAPIAgent
.post('tags/')
.body({
@ -43,31 +59,36 @@ describe('tag.* events', function () {
description: 'Test Description'
}]
})
.expectStatus(201);
.expectStatus(201); // 验证创建成功201 Created
// 等待webhook请求被接收
await webhookMockReceiver.receivedRequest();
// 验证webhook请求的头部和体部符合预期
webhookMockReceiver
.matchHeaderSnapshot({
'content-version': anyContentVersion,
'content-length': anyNumber,
'user-agent': anyGhostAgent
'content-version': anyContentVersion, // 匹配任意有效的内容版本
'content-length': anyNumber, // 匹配任意数字的内容长度
'user-agent': anyGhostAgent // 匹配任意Ghost客户端标识
})
.matchBodySnapshot({
tag: {
current: {...tagSnapshot, url: anyLocalURL}
current: {...tagSnapshot, url: anyLocalURL} // 当前标签数据包含快照字段和本地URL
}
});
});
// 测试用例tag.deleted事件被正确触发
it('tag.deleted event is triggered', async function () {
const webhookURL = 'https://test-webhook-receiver.com/tag-deleted/';
await webhookMockReceiver.mock(webhookURL);
// 插入一个监听"tag.deleted"事件的webhook
await fixtureManager.insertWebhook({
event: 'tag.deleted',
url: webhookURL
});
// 先创建一个标签用于后续删除
const res = await adminAPIAgent
.post('tags/')
.body({
@ -79,14 +100,18 @@ describe('tag.* events', function () {
})
.expectStatus(201);
// 获取新创建标签的ID
const id = res.body.tags[0].id;
// 删除该标签
await adminAPIAgent
.delete('tags/' + id)
.expectStatus(204);
.expectStatus(204); // 验证删除成功204 No Content
// 等待webhook请求被接收
await webhookMockReceiver.receivedRequest();
// 验证webhook请求的头部和体部符合预期
webhookMockReceiver
.matchHeaderSnapshot({
'content-version': anyContentVersion,
@ -95,20 +120,23 @@ describe('tag.* events', function () {
})
.matchBodySnapshot({
tag: {
current: {},
previous: tagSnapshot
current: {}, // 删除后当前数据为空
previous: tagSnapshot // 包含删除前的标签快照数据
}
});
});
// 测试用例tag.edited事件被正确触发
it('tag.edited event is triggered', async function () {
const webhookURL = 'https://test-webhook-receiver.com/tag-edited/';
await webhookMockReceiver.mock(webhookURL);
// 插入一个监听"tag.edited"事件的webhook
await fixtureManager.insertWebhook({
event: 'tag.edited',
url: webhookURL
});
// 先创建一个标签用于后续编辑
const res = await adminAPIAgent
.post('tags/')
.body({
@ -119,22 +147,27 @@ describe('tag.* events', function () {
}]
})
.expectStatus(201);
// 获取新创建标签的ID
const id = res.body.tags[0].id;
// 准备更新后的标签数据
const updatedTag = res.body.tags[0];
updatedTag.name = 'Updated Tag 3';
updatedTag.slug = 'updated-tag-3';
// 发送更新请求
await adminAPIAgent
.put('tags/' + id)
.body({
tags: [updatedTag]
})
.expectStatus(200);
.expectStatus(200); // 验证更新成功200 OK
// 等待webhook请求被接收
await webhookMockReceiver.receivedRequest();
// 验证webhook请求的头部和体部符合预期
webhookMockReceiver
.matchHeaderSnapshot({
'content-version': anyContentVersion,
@ -143,9 +176,9 @@ describe('tag.* events', function () {
})
.matchBodySnapshot({
tag: {
current: {...tagSnapshot, url: anyLocalURL},
previous: {updated_at: anyISODateTime}
current: {...tagSnapshot, url: anyLocalURL}, // 更新后的当前标签数据
previous: {updated_at: anyISODateTime} // 包含更新前的更新时间
}
});
});
});
});

@ -5,219 +5,301 @@ const urlService = require('../../../../core/server/services/url');
const models = require('../../../../core/server/models');
const tagsHelper = require('../../../../core/frontend/helpers/tags');
/**
* {{tags}} 助手函数的单元测试
* 验证标签助手在不同参数配置下的渲染结果
*/
describe('{{tags}} helper', function () {
// 声明URL服务的存根用于模拟URL生成
let urlServiceGetUrlByResourceIdStub;
// 测试套件启动前初始化模型
before(function () {
models.init();
});
// 每个测试用例执行前创建URL服务的存根
beforeEach(function () {
urlServiceGetUrlByResourceIdStub = sinon.stub(urlService, 'getUrlByResourceId');
});
// 每个测试用例执行后恢复所有存根
afterEach(function () {
sinon.restore();
});
/**
* 测试用例可以返回标签字符串
* 验证默认配置下标签的拼接格式
*/
it('can return string with tags', function () {
// 创建测试标签
const tags = [
testUtils.DataGenerator.forKnex.createTag({name: 'foo'}),
testUtils.DataGenerator.forKnex.createTag({name: 'bar'})
testUtils.DataGenerator.forKnex.createTag({ name: 'foo' }),
testUtils.DataGenerator.forKnex.createTag({ name: 'bar' })
];
const rendered = tagsHelper.call({tags: tags}, {hash: {autolink: 'false'}});
// 调用标签助手(关闭自动链接)
const rendered = tagsHelper.call({ tags: tags }, { hash: { autolink: 'false' } });
should.exist(rendered);
// 验证渲染结果为逗号分隔的标签名
String(rendered).should.equal('foo, bar');
});
/**
* 测试用例可以使用不同的分隔符
* 验证separator参数的效果
*/
it('can use a different separator', function () {
const tags = [
testUtils.DataGenerator.forKnex.createTag({name: 'haunted'}),
testUtils.DataGenerator.forKnex.createTag({name: 'ghost'})
testUtils.DataGenerator.forKnex.createTag({ name: 'haunted' }),
testUtils.DataGenerator.forKnex.createTag({ name: 'ghost' })
];
const rendered = tagsHelper.call({tags: tags}, {hash: {separator: '|', autolink: 'false'}});
// 使用|作为分隔符
const rendered = tagsHelper.call({ tags: tags }, { hash: { separator: '|', autolink: 'false' } });
should.exist(rendered);
String(rendered).should.equal('haunted|ghost');
});
/**
* 测试用例可以为多个标签添加前缀
* 验证prefix参数的效果
*/
it('can add a single prefix to multiple tags', function () {
const tags = [
testUtils.DataGenerator.forKnex.createTag({name: 'haunted'}),
testUtils.DataGenerator.forKnex.createTag({name: 'ghost'})
testUtils.DataGenerator.forKnex.createTag({ name: 'haunted' }),
testUtils.DataGenerator.forKnex.createTag({ name: 'ghost' })
];
const rendered = tagsHelper.call({tags: tags}, {hash: {prefix: 'on ', autolink: 'false'}});
const rendered = tagsHelper.call({ tags: tags }, { hash: { prefix: 'on ', autolink: 'false' } });
should.exist(rendered);
String(rendered).should.equal('on haunted, ghost');
});
/**
* 测试用例可以为多个标签添加后缀
* 验证suffix参数的效果
*/
it('can add a single suffix to multiple tags', function () {
const tags = [
testUtils.DataGenerator.forKnex.createTag({name: 'haunted'}),
testUtils.DataGenerator.forKnex.createTag({name: 'ghost'})
testUtils.DataGenerator.forKnex.createTag({ name: 'haunted' }),
testUtils.DataGenerator.forKnex.createTag({ name: 'ghost' })
];
const rendered = tagsHelper.call({tags: tags}, {hash: {suffix: ' forever', autolink: 'false'}});
const rendered = tagsHelper.call({ tags: tags }, { hash: { suffix: ' forever', autolink: 'false' } });
should.exist(rendered);
String(rendered).should.equal('haunted, ghost forever');
});
/**
* 测试用例可以同时添加前缀和后缀
* 验证prefix和suffix参数的组合效果
*/
it('can add a prefix and suffix to multiple tags', function () {
const tags = [
testUtils.DataGenerator.forKnex.createTag({name: 'haunted'}),
testUtils.DataGenerator.forKnex.createTag({name: 'ghost'})
testUtils.DataGenerator.forKnex.createTag({ name: 'haunted' }),
testUtils.DataGenerator.forKnex.createTag({ name: 'ghost' })
];
const rendered = tagsHelper.call({tags: tags}, {hash: {suffix: ' forever', prefix: 'on ', autolink: 'false'}});
const rendered = tagsHelper.call({ tags: tags }, { hash: { suffix: ' forever', prefix: 'on ', autolink: 'false' } });
should.exist(rendered);
String(rendered).should.equal('on haunted, ghost forever');
});
/**
* 测试用例可以添加包含HTML的前缀和后缀
* 验证HTML内容在前缀/后缀中的保留
*/
it('can add a prefix and suffix with HTML', function () {
const tags = [
testUtils.DataGenerator.forKnex.createTag({name: 'haunted'}),
testUtils.DataGenerator.forKnex.createTag({name: 'ghost'})
testUtils.DataGenerator.forKnex.createTag({ name: 'haunted' }),
testUtils.DataGenerator.forKnex.createTag({ name: 'ghost' })
];
const rendered = tagsHelper.call({tags: tags}, {hash: {suffix: ' &bull;', prefix: '&hellip; ', autolink: 'false'}});
const rendered = tagsHelper.call({ tags: tags }, { hash: { suffix: ' &bull;', prefix: '&hellip; ', autolink: 'false' } });
should.exist(rendered);
String(rendered).should.equal('&hellip; haunted, ghost &bull;');
});
/**
* 测试用例无标签时不添加前缀或后缀
* 验证边界条件下的处理
*/
it('does not add prefix or suffix if no tags exist', function () {
const rendered = tagsHelper.call({}, {hash: {prefix: 'on ', suffix: ' forever', autolink: 'false'}});
const rendered = tagsHelper.call({}, { hash: { prefix: 'on ', suffix: ' forever', autolink: 'false' } });
should.exist(rendered);
String(rendered).should.equal('');
});
/**
* 测试用例可以自动链接标签到标签页面
* 验证autolink默认开启时的链接生成
*/
it('can autolink tags to tag pages', function () {
const tags = [
testUtils.DataGenerator.forKnex.createTag({name: 'foo', slug: 'foo-bar'}),
testUtils.DataGenerator.forKnex.createTag({name: 'bar', slug: 'bar'})
testUtils.DataGenerator.forKnex.createTag({ name: 'foo', slug: 'foo-bar' }),
testUtils.DataGenerator.forKnex.createTag({ name: 'bar', slug: 'bar' })
];
// 模拟URL服务返回的标签页面URL
urlServiceGetUrlByResourceIdStub.withArgs(tags[0].id).returns('tag url 1');
urlServiceGetUrlByResourceIdStub.withArgs(tags[1].id).returns('tag url 2');
const rendered = tagsHelper.call({tags: tags});
const rendered = tagsHelper.call({ tags: tags });
should.exist(rendered);
// 验证渲染结果为带链接的标签
String(rendered).should.equal('<a href="tag url 1">foo</a>, <a href="tag url 2">bar</a>');
});
/**
* 测试用例可以限制输出的标签数量为1
* 验证limit参数的效果
*/
it('can limit no. tags output to 1', function () {
const tags = [
testUtils.DataGenerator.forKnex.createTag({name: 'foo', slug: 'foo-bar'}),
testUtils.DataGenerator.forKnex.createTag({name: 'bar', slug: 'bar'})
testUtils.DataGenerator.forKnex.createTag({ name: 'foo', slug: 'foo-bar' }),
testUtils.DataGenerator.forKnex.createTag({ name: 'bar', slug: 'bar' })
];
urlServiceGetUrlByResourceIdStub.withArgs(tags[0].id).returns('tag url 1');
const rendered = tagsHelper.call({tags: tags}, {hash: {limit: '1'}});
const rendered = tagsHelper.call({ tags: tags }, { hash: { limit: '1' } });
should.exist(rendered);
String(rendered).should.equal('<a href="tag url 1">foo</a>');
});
/**
* 测试用例可以从指定位置开始列出标签
* 验证from参数的效果
*/
it('can list tags from a specified no.', function () {
const tags = [
testUtils.DataGenerator.forKnex.createTag({name: 'foo', slug: 'foo-bar'}),
testUtils.DataGenerator.forKnex.createTag({name: 'bar', slug: 'bar'})
testUtils.DataGenerator.forKnex.createTag({ name: 'foo', slug: 'foo-bar' }),
testUtils.DataGenerator.forKnex.createTag({ name: 'bar', slug: 'bar' })
];
urlServiceGetUrlByResourceIdStub.withArgs(tags[1].id).returns('tag url 2');
const rendered = tagsHelper.call({tags: tags}, {hash: {from: '2'}});
// 从第2个标签开始输出
const rendered = tagsHelper.call({ tags: tags }, { hash: { from: '2' } });
should.exist(rendered);
String(rendered).should.equal('<a href="tag url 2">bar</a>');
});
/**
* 测试用例可以输出到指定位置的标签
* 验证to参数的效果
*/
it('can list tags to a specified no.', function () {
const tags = [
testUtils.DataGenerator.forKnex.createTag({name: 'foo', slug: 'foo-bar'}),
testUtils.DataGenerator.forKnex.createTag({name: 'bar', slug: 'bar'})
testUtils.DataGenerator.forKnex.createTag({ name: 'foo', slug: 'foo-bar' }),
testUtils.DataGenerator.forKnex.createTag({ name: 'bar', slug: 'bar' })
];
urlServiceGetUrlByResourceIdStub.withArgs(tags[0].id).returns('tag url x');
const rendered = tagsHelper.call({tags: tags}, {hash: {to: '1'}});
// 输出到第1个标签
const rendered = tagsHelper.call({ tags: tags }, { hash: { to: '1' } });
should.exist(rendered);
String(rendered).should.equal('<a href="tag url x">foo</a>');
});
/**
* 测试用例可以输出指定范围内的标签
* 验证from和to参数的组合效果
*/
it('can list tags in a range', function () {
const tags = [
testUtils.DataGenerator.forKnex.createTag({name: 'foo', slug: 'foo-bar'}),
testUtils.DataGenerator.forKnex.createTag({name: 'bar', slug: 'bar'}),
testUtils.DataGenerator.forKnex.createTag({name: 'baz', slug: 'baz'})
testUtils.DataGenerator.forKnex.createTag({ name: 'foo', slug: 'foo-bar' }),
testUtils.DataGenerator.forKnex.createTag({ name: 'bar', slug: 'bar' }),
testUtils.DataGenerator.forKnex.createTag({ name: 'baz', slug: 'baz' })
];
urlServiceGetUrlByResourceIdStub.withArgs(tags[1].id).returns('tag url b');
urlServiceGetUrlByResourceIdStub.withArgs(tags[2].id).returns('tag url c');
const rendered = tagsHelper.call({tags: tags}, {hash: {from: '2', to: '3'}});
// 输出第2到第3个标签
const rendered = tagsHelper.call({ tags: tags }, { hash: { from: '2', to: '3' } });
should.exist(rendered);
String(rendered).should.equal('<a href="tag url b">bar</a>, <a href="tag url c">baz</a>');
});
/**
* 测试用例可以限制标签数量并从指定位置开始
* 验证from和limit参数的组合效果
*/
it('can limit no. tags and output from 2', function () {
const tags = [
testUtils.DataGenerator.forKnex.createTag({name: 'foo', slug: 'foo-bar'}),
testUtils.DataGenerator.forKnex.createTag({name: 'bar', slug: 'bar'}),
testUtils.DataGenerator.forKnex.createTag({name: 'baz', slug: 'baz'})
testUtils.DataGenerator.forKnex.createTag({ name: 'foo', slug: 'foo-bar' }),
testUtils.DataGenerator.forKnex.createTag({ name: 'bar', slug: 'bar' }),
testUtils.DataGenerator.forKnex.createTag({ name: 'baz', slug: 'baz' })
];
urlServiceGetUrlByResourceIdStub.withArgs(tags[1].id).returns('tag url b');
const rendered = tagsHelper.call({tags: tags}, {hash: {from: '2', limit: '1'}});
// 从第2个标签开始输出1个标签
const rendered = tagsHelper.call({ tags: tags }, { hash: { from: '2', limit: '1' } });
should.exist(rendered);
String(rendered).should.equal('<a href="tag url b">bar</a>');
});
/**
* 测试用例在指定范围内输出标签时忽略limit参数
* 验证fromto参数优先于limit参数
*/
it('can list tags in a range (ignore limit)', function () {
const tags = [
testUtils.DataGenerator.forKnex.createTag({name: 'foo', slug: 'foo-bar'}),
testUtils.DataGenerator.forKnex.createTag({name: 'bar', slug: 'bar'}),
testUtils.DataGenerator.forKnex.createTag({name: 'baz', slug: 'baz'})
testUtils.DataGenerator.forKnex.createTag({ name: 'foo', slug: 'foo-bar' }),
testUtils.DataGenerator.forKnex.createTag({ name: 'bar', slug: 'bar' }),
testUtils.DataGenerator.forKnex.createTag({ name: 'baz', slug: 'baz' })
];
urlServiceGetUrlByResourceIdStub.withArgs(tags[0].id).returns('tag url a');
urlServiceGetUrlByResourceIdStub.withArgs(tags[1].id).returns('tag url b');
urlServiceGetUrlByResourceIdStub.withArgs(tags[2].id).returns('tag url c');
const rendered = tagsHelper.call({tags: tags}, {hash: {from: '1', to: '3', limit: '2'}});
// 范围为1-3时忽略limit=2
const rendered = tagsHelper.call({ tags: tags }, { hash: { from: '1', to: '3', limit: '2' } });
should.exist(rendered);
String(rendered).should.equal('<a href="tag url a">foo</a>, <a href="tag url b">bar</a>, <a href="tag url c">baz</a>');
});
/**
* 描述"内部标签Internal tags"的测试场景
* 验证不同可见性配置下的标签渲染
*/
describe('Internal tags', function () {
// 准备包含内部标签和普通标签的测试数据
const tags = [
testUtils.DataGenerator.forKnex.createTag({name: 'foo', slug: 'foo-bar'}),
testUtils.DataGenerator.forKnex.createTag({name: '#bar', slug: 'hash-bar', visibility: 'internal'}),
testUtils.DataGenerator.forKnex.createTag({name: 'bar', slug: 'bar'}),
testUtils.DataGenerator.forKnex.createTag({name: 'baz', slug: 'baz'}),
testUtils.DataGenerator.forKnex.createTag({name: 'buzz', slug: 'buzz'})
testUtils.DataGenerator.forKnex.createTag({ name: 'foo', slug: 'foo-bar' }),
testUtils.DataGenerator.forKnex.createTag({ name: '#bar', slug: 'hash-bar', visibility: 'internal' }),
testUtils.DataGenerator.forKnex.createTag({ name: 'bar', slug: 'bar' }),
testUtils.DataGenerator.forKnex.createTag({ name: 'baz', slug: 'baz' }),
testUtils.DataGenerator.forKnex.createTag({ name: 'buzz', slug: 'buzz' })
];
// 准备全是内部标签的测试数据
const tags1 = [
testUtils.DataGenerator.forKnex.createTag({name: '#foo', slug: 'hash-foo-bar', visibility: 'internal'}),
testUtils.DataGenerator.forKnex.createTag({name: '#bar', slug: 'hash-bar', visibility: 'internal'})
testUtils.DataGenerator.forKnex.createTag({ name: '#foo', slug: 'hash-foo-bar', visibility: 'internal' }),
testUtils.DataGenerator.forKnex.createTag({ name: '#bar', slug: 'hash-bar', visibility: 'internal' })
];
// 每个子测试用例执行前设置URL服务的返回值
beforeEach(function () {
urlServiceGetUrlByResourceIdStub.withArgs(tags[0].id).returns('1');
urlServiceGetUrlByResourceIdStub.withArgs(tags[1].id).returns('2');
@ -226,8 +308,12 @@ describe('{{tags}} helper', function () {
urlServiceGetUrlByResourceIdStub.withArgs(tags[4].id).returns('5');
});
/**
* 测试用例默认不输出内部标签
* 验证默认可见性配置
*/
it('will not output internal tags by default', function () {
const rendered = tagsHelper.call({tags: tags});
const rendered = tagsHelper.call({ tags: tags });
String(rendered).should.equal(
'<a href="1">foo</a>, ' +
@ -237,8 +323,12 @@ describe('{{tags}} helper', function () {
);
});
/**
* 测试用例正确应用from和limit参数忽略内部标签
* 验证参数在包含内部标签时的处理逻辑
*/
it('should still correctly apply from & limit tags', function () {
const rendered = tagsHelper.call({tags: tags}, {hash: {from: '2', limit: '2'}});
const rendered = tagsHelper.call({ tags: tags }, { hash: { from: '2', limit: '2' } });
String(rendered).should.equal(
'<a href="3">bar</a>, ' +
@ -246,8 +336,12 @@ describe('{{tags}} helper', function () {
);
});
/**
* 测试用例visibility="all"时输出所有标签
* 验证显示所有标签的配置
*/
it('should output all tags with visibility="all"', function () {
const rendered = tagsHelper.call({tags: tags}, {hash: {visibility: 'all'}});
const rendered = tagsHelper.call({ tags: tags }, { hash: { visibility: 'all' } });
String(rendered).should.equal(
'<a href="1">foo</a>, ' +
@ -258,8 +352,12 @@ describe('{{tags}} helper', function () {
);
});
/**
* 测试用例visibility="public,internal"时输出所有标签
* 验证多可见性组合的配置
*/
it('should output all tags with visibility property set with visibility="public,internal"', function () {
const rendered = tagsHelper.call({tags: tags}, {hash: {visibility: 'public,internal'}});
const rendered = tagsHelper.call({ tags: tags }, { hash: { visibility: 'public,internal' } });
should.exist(rendered);
String(rendered).should.equal(
@ -271,18 +369,26 @@ describe('{{tags}} helper', function () {
);
});
/**
* 测试用例visibility="internal"时只输出内部标签
* 验证只显示内部标签的配置
*/
it('Should output only internal tags with visibility="internal"', function () {
const rendered = tagsHelper.call({tags: tags}, {hash: {visibility: 'internal'}});
const rendered = tagsHelper.call({ tags: tags }, { hash: { visibility: 'internal' } });
should.exist(rendered);
String(rendered).should.equal('<a href="2">#bar</a>');
});
/**
* 测试用例所有标签都是内部标签时输出空
* 验证默认配置下无可见标签的处理
*/
it('should output nothing if all tags are internal', function () {
const rendered = tagsHelper.call({tags: tags1}, {hash: {prefix: 'stuff'}});
const rendered = tagsHelper.call({ tags: tags1 }, { hash: { prefix: 'stuff' } });
should.exist(rendered);
String(rendered).should.equal('');
});
});
});
});
Loading…
Cancel
Save