feat(web): страницы материалов курса
This commit is contained in:
@@ -43,6 +43,36 @@ fun ApiClient.contentTreeList(token: String?): List<ContentArticleDto> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Дерево материалов курса: доступно назначенным преподавателям, студентам групп курса и админу. */
|
||||
fun ApiClient.courseContentTree(
|
||||
token: String,
|
||||
courseId: String,
|
||||
): ContentTreeNodeDto =
|
||||
call {
|
||||
client
|
||||
.get()
|
||||
.uri("/api/v1/courses/{courseId}/content/tree", courseId)
|
||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
||||
.retrieve()
|
||||
.body(ContentTreeNodeDto::class.java)
|
||||
}
|
||||
|
||||
fun ApiClient.courseContentArticle(
|
||||
token: String,
|
||||
courseId: String,
|
||||
chapter: String,
|
||||
section: String,
|
||||
article: String,
|
||||
): ContentArticleDto =
|
||||
call {
|
||||
client
|
||||
.get()
|
||||
.uri("/api/v1/courses/{courseId}/content/{c}/{s}/{a}", courseId, chapter, section, article)
|
||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
||||
.retrieve()
|
||||
.body(ContentArticleDto::class.java)
|
||||
}
|
||||
|
||||
fun ApiClient.importContent(
|
||||
token: String,
|
||||
filename: String,
|
||||
|
||||
@@ -57,6 +57,20 @@ fun ApiClient.courseDetail(
|
||||
.body(CourseDetailDto::class.java)
|
||||
}
|
||||
|
||||
/** Курс по человекочитаемому номеру из URL (`course_no`). */
|
||||
fun ApiClient.courseByNo(
|
||||
token: String,
|
||||
courseNo: Long,
|
||||
): CourseDetailDto =
|
||||
call {
|
||||
client
|
||||
.get()
|
||||
.uri("/api/v1/courses/by-no/{courseNo}", courseNo)
|
||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
||||
.retrieve()
|
||||
.body(CourseDetailDto::class.java)
|
||||
}
|
||||
|
||||
/** Состав группы в рамках курса: доступен преподавателю курса и администратору. */
|
||||
fun ApiClient.courseGroupStudents(
|
||||
token: String,
|
||||
|
||||
@@ -57,7 +57,7 @@ class WebConfig(
|
||||
)
|
||||
registry
|
||||
.addInterceptor(authInterceptor)
|
||||
.addPathPatterns("/admin/**", "/student/**", "/teacher/**", "/change-password")
|
||||
.addPathPatterns("/admin/**", "/student/**", "/teacher/**", "/change-password", "/learn/course/**")
|
||||
registry
|
||||
.addInterceptor(adminInterceptor)
|
||||
.addPathPatterns("/admin/**")
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
package ru.ysu.lms.web.learn
|
||||
|
||||
import jakarta.servlet.http.HttpSession
|
||||
import org.springframework.stereotype.Controller
|
||||
import org.springframework.ui.Model
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import ru.ysu.lms.markdown.MarkdownRenderer
|
||||
import ru.ysu.lms.web.api.ApiClient
|
||||
import ru.ysu.lms.web.api.ApiException
|
||||
import ru.ysu.lms.web.api.ContentArticleDto
|
||||
import ru.ysu.lms.web.api.ContentTreeNodeDto
|
||||
import ru.ysu.lms.web.api.courseByNo
|
||||
import ru.ysu.lms.web.api.courseContentArticle
|
||||
import ru.ysu.lms.web.api.courseContentTree
|
||||
import ru.ysu.lms.web.session.SessionKeys
|
||||
|
||||
/** Ссылка на статью курса для навигации «назад/далее» и бокового содержания. */
|
||||
data class CourseLearnArticleLink(
|
||||
val chapterSlug: String = "",
|
||||
val chapterTitle: String = "",
|
||||
val sectionSlug: String = "",
|
||||
val sectionTitle: String = "",
|
||||
val slug: String = "",
|
||||
val title: String = "",
|
||||
)
|
||||
|
||||
/**
|
||||
* Материалы курса для авторизованных пользователей. Доступ к самому курсу
|
||||
* проверяет academic-service по токену из сессии; анонимного пользователя
|
||||
* разворачивает AuthInterceptor.
|
||||
*/
|
||||
@Controller
|
||||
class CourseLearnController(
|
||||
private val api: ApiClient,
|
||||
private val markdown: MarkdownRenderer,
|
||||
) {
|
||||
private fun token(session: HttpSession): String? = session.getAttribute(SessionKeys.TOKEN) as? String
|
||||
|
||||
private fun treeContainsArticle(
|
||||
tree: ContentTreeNodeDto,
|
||||
chapter: String,
|
||||
section: String,
|
||||
article: String,
|
||||
): Boolean {
|
||||
val sec =
|
||||
tree.chapters
|
||||
.firstOrNull { it.slug == chapter }
|
||||
?.sections
|
||||
?.firstOrNull { it.slug == section }
|
||||
?: return false
|
||||
return sec.articles.any { it.slug == article }
|
||||
}
|
||||
|
||||
@GetMapping("/learn/course/{courseNo}")
|
||||
fun index(
|
||||
@PathVariable courseNo: Long,
|
||||
session: HttpSession,
|
||||
model: Model,
|
||||
): String {
|
||||
val t = token(session) ?: return "redirect:/login"
|
||||
val course = api.courseByNo(t, courseNo)
|
||||
model.addAttribute("course", course)
|
||||
model.addAttribute("tree", api.courseContentTree(t, course.id))
|
||||
return "learn/course-index"
|
||||
}
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
@GetMapping("/learn/course/{courseNo}/{chapter}/{section}/{article}")
|
||||
fun article(
|
||||
@PathVariable courseNo: Long,
|
||||
@PathVariable chapter: String,
|
||||
@PathVariable section: String,
|
||||
@PathVariable article: String,
|
||||
session: HttpSession,
|
||||
model: Model,
|
||||
): String {
|
||||
val t = token(session) ?: return "redirect:/login"
|
||||
val course = api.courseByNo(t, courseNo)
|
||||
val tree: ContentTreeNodeDto = api.courseContentTree(t, course.id)
|
||||
if (!treeContainsArticle(tree, chapter, section, article)) return "redirect:/learn/course/$courseNo"
|
||||
|
||||
val a: ContentArticleDto =
|
||||
try {
|
||||
api.courseContentArticle(t, course.id, chapter, section, article)
|
||||
} catch (ignore: ApiException) {
|
||||
return "redirect:/learn/course/$courseNo"
|
||||
}
|
||||
|
||||
val flat =
|
||||
tree.chapters.flatMap { c ->
|
||||
c.sections.flatMap { s ->
|
||||
s.articles.map { art ->
|
||||
CourseLearnArticleLink(
|
||||
chapterSlug = c.slug,
|
||||
chapterTitle = c.title,
|
||||
sectionSlug = s.slug,
|
||||
sectionTitle = s.title,
|
||||
slug = art.slug,
|
||||
title = art.title,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
val index =
|
||||
flat.indexOfFirst {
|
||||
it.chapterSlug == chapter && it.sectionSlug == section && it.slug == article
|
||||
}
|
||||
model.addAttribute("course", course)
|
||||
model.addAttribute("tree", tree)
|
||||
model.addAttribute("article", a.toView(markdown.render(a.bodyMd)))
|
||||
model.addAttribute("prevArticle", flat.getOrNull(index - 1))
|
||||
model.addAttribute("nextArticle", flat.getOrNull(index + 1))
|
||||
return "learn/course-article"
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,8 @@ class StudentController(
|
||||
val grade: String?,
|
||||
val submissionSeq: Int?,
|
||||
val submissionCount: Int,
|
||||
val courseNo: Long? = null,
|
||||
val courseTitle: String? = null,
|
||||
)
|
||||
|
||||
data class StudentAttestationView(
|
||||
@@ -268,6 +270,8 @@ class StudentController(
|
||||
grade = grades[lab.id]?.score,
|
||||
submissionSeq = subs.firstOrNull()?.seq,
|
||||
submissionCount = subs.size,
|
||||
courseNo = lab.courseNo,
|
||||
courseTitle = lab.courseTitle,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,9 @@
|
||||
</div>
|
||||
<span th:text="|Создан: ${course.createdAt}|">Создан: —</span>
|
||||
· <span th:text="|Обновлён: ${course.updatedAt}|">Обновлён: —</span>
|
||||
<div class="mt-2">
|
||||
<a class="btn btn-sm btn-secondary" th:href="@{/learn/course/{no}(no=${course.courseNo})}">Учебные материалы курса</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
98
web/src/main/resources/templates/learn/course-article.html
Normal file
98
web/src/main/resources/templates/learn/course-article.html
Normal file
@@ -0,0 +1,98 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru" xmlns:th="http://www.thymeleaf.org" th:replace="~{fragments/base :: layout(~{::title}, ~{::#content})}">
|
||||
<head><title th:text="${article.title} + ' — LMS'">Статья курса</title></head>
|
||||
<body>
|
||||
<div id="content">
|
||||
<div class="row g-4">
|
||||
<div class="col-12 col-lg-3 order-lg-1 order-2">
|
||||
<aside class="learn-side offcanvas offcanvas-lg offcanvas-end" tabindex="-1" id="articleSide" aria-label="Содержание">
|
||||
<div class="offcanvas-header d-lg-none">
|
||||
<span class="offcanvas-title">Содержание</span>
|
||||
<button type="button" class="navbar-close" data-bs-dismiss="offcanvas" aria-label="Закрыть">✕</button>
|
||||
</div>
|
||||
<div class="offcanvas-body p-0">
|
||||
<div class="card card-body">
|
||||
<p class="text-sm text-muted mb-2">Содержание</p>
|
||||
<a class="text-sm font-medium" th:href="@{/learn/course/{no}(no=${course.courseNo})}"
|
||||
th:text="${course.title}">Курс</a>
|
||||
<div th:each="c : ${tree.chapters}" class="mt-2">
|
||||
<div class="side-chapter">
|
||||
<span th:text="${c.title}">Глава</span>
|
||||
<span th:if="${!c.published}" class="badge badge-warn">скрыто</span>
|
||||
</div>
|
||||
<div th:each="s : ${c.sections}" class="side-section">
|
||||
<span th:text="${s.title}">Раздел</span>
|
||||
<span th:if="${!s.published}" class="badge badge-warn">скрыто</span>
|
||||
<ul class="side-articles">
|
||||
<li th:each="a : ${s.articles}">
|
||||
<a th:href="@{/learn/course/{no}/{c2}/{s2}/{a2}(no=${course.courseNo}, c2=${c.slug}, s2=${s.slug}, a2=${a.slug})}"
|
||||
th:text="${a.title}"
|
||||
th:classappend="${a.slug == article.slug and s.slug == article.sectionSlug and c.slug == article.chapterSlug} ? 'side-current'">Статья</a>
|
||||
<span th:if="${!a.published}" class="badge badge-warn">скрыто</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-lg-9 order-1">
|
||||
<nav class="text-sm mb-4 breadcrumb">
|
||||
<a th:href="@{/learn/course/{no}(no=${course.courseNo})}" class="nav-link" th:text="${course.title}">Курс</a>
|
||||
<span class="nav-sep">/</span>
|
||||
<span class="nav-link" th:text="${article.chapterTitle}">Глава</span>
|
||||
<span class="nav-sep">/</span>
|
||||
<span class="nav-link" th:text="${article.sectionTitle}">Раздел</span>
|
||||
<span class="nav-sep">/</span>
|
||||
<span th:text="${article.title}">Статья</span>
|
||||
</nav>
|
||||
|
||||
<div class="d-flex align-items-start justify-content-between gap-2 mb-3">
|
||||
<h1 class="page-title m-0">
|
||||
<span th:text="${article.title}">Статья</span>
|
||||
<span th:if="${!article.published}" class="badge badge-warn ml-2">скрыто от студентов</span>
|
||||
</h1>
|
||||
<button class="btn btn-secondary btn-sm d-lg-none" type="button"
|
||||
data-bs-toggle="offcanvas" data-bs-target="#articleSide" aria-controls="articleSide">
|
||||
Содержание
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2 mb-4">
|
||||
<a class="btn btn-secondary btn-sm" th:href="@{/learn/course/{no}(no=${course.courseNo})}">← К материалам курса</a>
|
||||
</div>
|
||||
|
||||
<nav class="article-nav mb-4" th:if="${prevArticle != null or nextArticle != null}">
|
||||
<a th:if="${prevArticle != null}" class="btn btn-secondary"
|
||||
th:href="@{/learn/course/{no}/{c}/{s}/{a}(no=${course.courseNo}, c=${prevArticle.chapterSlug}, s=${prevArticle.sectionSlug}, a=${prevArticle.slug})}"
|
||||
th:title="|${prevArticle.chapterTitle} / ${prevArticle.sectionTitle}|">
|
||||
← <span th:text="${prevArticle.title}">Предыдущая</span>
|
||||
</a>
|
||||
<span th:if="${prevArticle == null}"></span>
|
||||
<a th:if="${nextArticle != null}" class="btn btn-secondary ml-auto"
|
||||
th:href="@{/learn/course/{no}/{c}/{s}/{a}(no=${course.courseNo}, c=${nextArticle.chapterSlug}, s=${nextArticle.sectionSlug}, a=${nextArticle.slug})}"
|
||||
th:title="|${nextArticle.chapterTitle} / ${nextArticle.sectionTitle}|">
|
||||
<span th:text="${nextArticle.title}">Следующая</span> →
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<div id="article-view" class="card card-body md-body" th:utext="${article.bodyHtml}"></div>
|
||||
|
||||
<nav class="article-nav mt-6" th:if="${prevArticle != null or nextArticle != null}">
|
||||
<a th:if="${prevArticle != null}" class="btn btn-secondary"
|
||||
th:href="@{/learn/course/{no}/{c}/{s}/{a}(no=${course.courseNo}, c=${prevArticle.chapterSlug}, s=${prevArticle.sectionSlug}, a=${prevArticle.slug})}">
|
||||
← <span th:text="${prevArticle.title}">Предыдущая</span>
|
||||
</a>
|
||||
<span th:if="${prevArticle == null}"></span>
|
||||
<a th:if="${nextArticle != null}" class="btn btn-primary ml-auto"
|
||||
th:href="@{/learn/course/{no}/{c}/{s}/{a}(no=${course.courseNo}, c=${nextArticle.chapterSlug}, s=${nextArticle.sectionSlug}, a=${nextArticle.slug})}">
|
||||
<span th:text="${nextArticle.title}">Следующая</span> →
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
50
web/src/main/resources/templates/learn/course-index.html
Normal file
50
web/src/main/resources/templates/learn/course-index.html
Normal file
@@ -0,0 +1,50 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru" xmlns:th="http://www.thymeleaf.org" th:replace="~{fragments/base :: layout(~{::title}, ~{::#content})}">
|
||||
<head><title th:text="${course.title} + ' — LMS'">Материалы курса</title></head>
|
||||
<body>
|
||||
<div id="content">
|
||||
<nav class="text-sm mb-4 breadcrumb">
|
||||
<a th:href="@{/learn}" class="nav-link">Учебные материалы</a>
|
||||
<span class="nav-sep">/</span>
|
||||
<span th:text="${course.title}">Курс</span>
|
||||
</nav>
|
||||
|
||||
<div class="page-head mb-2">
|
||||
<div>
|
||||
<h1 class="page-title" th:text="${course.title}">Курс</h1>
|
||||
<div class="meta">
|
||||
<span class="badge"
|
||||
th:classappend="${course.status == 'ACTIVE' ? 'badge-ok' : 'badge-warn'}"
|
||||
th:text="${course.status == 'ACTIVE' ? 'активен' : 'в архиве'}">—</span>
|
||||
<span th:text="|Курс №${course.courseNo}|">Курс №1</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div th:if="${tree == null || #lists.isEmpty(tree.chapters)}" class="card card-body text-muted mt-4">
|
||||
В этом курсе пока нет учебных материалов.
|
||||
</div>
|
||||
|
||||
<div th:each="chapter : ${tree?.chapters}" class="card card-body chapter-card mb-4 mt-4">
|
||||
<h2 class="section-title m-0" th:text="${chapter.title}">Глава</h2>
|
||||
<span th:if="${!chapter.published}" class="badge badge-warn mt-2">скрыто от студентов</span>
|
||||
|
||||
<p th:if="${#lists.isEmpty(chapter.sections)}" class="text-sm text-muted mt-2 mb-0">Разделов пока нет</p>
|
||||
|
||||
<div th:each="section : ${chapter.sections}" class="mt-3">
|
||||
<h3 class="group-title m-0" th:text="${section.title}">Раздел</h3>
|
||||
<span th:if="${!section.published}" class="badge badge-warn">скрыто от студентов</span>
|
||||
<ul class="sort-list text-sm mt-2">
|
||||
<li th:each="article : ${section.articles}">
|
||||
<a class="sort-title"
|
||||
th:href="@{/learn/course/{no}/{c}/{s}/{a}(no=${course.courseNo}, c=${chapter.slug}, s=${section.slug}, a=${article.slug})}"
|
||||
th:text="${article.title}">Статья</a>
|
||||
<span th:if="${!article.published}" class="badge badge-warn">скрыто</span>
|
||||
</li>
|
||||
<li th:if="${#lists.isEmpty(section.articles)}" class="text-muted">Статей пока нет</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -61,6 +61,9 @@
|
||||
<span th:unless="${c.submissionSeq}" class="badge badge-warn">не сдано</span>
|
||||
<span th:if="${c.grade}" class="badge badge-ok">оценка: <span th:text="${c.grade}"></span></span>
|
||||
</div>
|
||||
<div class="mt-2" th:if="${c.courseNo != null}">
|
||||
<a class="text-sm" th:href="@{/learn/course/{no}(no=${c.courseNo})}">Учебные материалы курса</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -21,6 +21,9 @@
|
||||
<span th:if="${c.deadlineMsk != null and c.defenseMsk != null}" class="nav-sep">·</span>
|
||||
<span th:if="${c.defenseMsk}" th:text="|Защита: ${c.defenseMsk} МСК|">—</span>
|
||||
</p>
|
||||
<div class="mt-2" th:if="${c.courseNo != null}">
|
||||
<a class="text-sm" th:href="@{/learn/course/{no}(no=${c.courseNo})}">Учебные материалы курса</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -15,6 +15,9 @@
|
||||
<span th:text="|Групп: ${#lists.size(groups)}|">Групп: 0</span>
|
||||
· <span th:text="|Лабораторных: ${#lists.size(labs)}|">Лабораторных: 0</span>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<a class="btn btn-sm btn-secondary" th:href="@{/learn/course/{no}(no=${course.courseNo})}">Учебные материалы курса</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
package ru.ysu.lms.web.learn
|
||||
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.springframework.mock.web.MockHttpServletRequest
|
||||
import org.springframework.mock.web.MockHttpServletResponse
|
||||
import org.springframework.mock.web.MockServletContext
|
||||
import org.thymeleaf.context.WebContext
|
||||
import org.thymeleaf.spring6.SpringTemplateEngine
|
||||
import org.thymeleaf.templatemode.TemplateMode
|
||||
import org.thymeleaf.templateresolver.ClassLoaderTemplateResolver
|
||||
import org.thymeleaf.web.servlet.JakartaServletWebApplication
|
||||
import ru.ysu.lms.web.api.ContentArticleNodeDto
|
||||
import ru.ysu.lms.web.api.ContentChapterNodeDto
|
||||
import ru.ysu.lms.web.api.ContentSectionNodeDto
|
||||
import ru.ysu.lms.web.api.ContentTreeNodeDto
|
||||
import ru.ysu.lms.web.api.CourseDetailDto
|
||||
import java.util.Locale
|
||||
|
||||
class CourseLearnTemplateTest {
|
||||
private val engine =
|
||||
SpringTemplateEngine().apply {
|
||||
setTemplateResolver(
|
||||
ClassLoaderTemplateResolver().apply {
|
||||
prefix = "templates/"
|
||||
suffix = ".html"
|
||||
characterEncoding = "UTF-8"
|
||||
templateMode = TemplateMode.HTML
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private val course = CourseDetailDto(id = "course-id", courseNo = 42, title = "Алгоритмы")
|
||||
|
||||
private val tree =
|
||||
ContentTreeNodeDto(
|
||||
chapters =
|
||||
listOf(
|
||||
ContentChapterNodeDto(
|
||||
id = "ch1",
|
||||
title = "Введение",
|
||||
slug = "intro",
|
||||
sections =
|
||||
listOf(
|
||||
ContentSectionNodeDto(
|
||||
id = "sec1",
|
||||
title = "Основы",
|
||||
slug = "basics",
|
||||
articles =
|
||||
listOf(
|
||||
ContentArticleNodeDto(
|
||||
id = "art1",
|
||||
title = "Первый шаг",
|
||||
slug = "first-step",
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `course overview links to course article pages`() {
|
||||
val html = render("learn/course-index", mapOf("course" to course, "tree" to tree))
|
||||
|
||||
assertThat(html).contains("Алгоритмы")
|
||||
assertThat(html).contains("Введение")
|
||||
assertThat(html).contains("Основы")
|
||||
assertThat(html).contains("Первый шаг")
|
||||
assertThat(html).contains("/learn/course/42/intro/basics/first-step")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `course article shows breadcrumbs and back link`() {
|
||||
val article =
|
||||
LearnArticleView(
|
||||
id = "art1",
|
||||
chapterSlug = "intro",
|
||||
chapterTitle = "Введение",
|
||||
sectionSlug = "basics",
|
||||
sectionTitle = "Основы",
|
||||
slug = "first-step",
|
||||
title = "Первый шаг",
|
||||
bodyHtml = "<p>Текст статьи</p>",
|
||||
bodyMd = "Текст статьи",
|
||||
published = true,
|
||||
)
|
||||
val html =
|
||||
render(
|
||||
"learn/course-article",
|
||||
mapOf(
|
||||
"course" to course,
|
||||
"tree" to tree,
|
||||
"article" to article,
|
||||
"prevArticle" to null,
|
||||
"nextArticle" to null,
|
||||
),
|
||||
)
|
||||
|
||||
assertThat(html).contains("Текст статьи")
|
||||
assertThat(html).contains("/learn/course/42")
|
||||
assertThat(html).contains("Введение")
|
||||
assertThat(html).contains("Основы")
|
||||
assertThat(html).contains("← К материалам курса")
|
||||
}
|
||||
|
||||
private fun render(
|
||||
template: String,
|
||||
variables: Map<String, Any?>,
|
||||
): String {
|
||||
val servletContext = MockServletContext()
|
||||
val application = JakartaServletWebApplication.buildApplication(servletContext)
|
||||
val exchange =
|
||||
application.buildExchange(
|
||||
MockHttpServletRequest(servletContext),
|
||||
MockHttpServletResponse(),
|
||||
)
|
||||
val context = WebContext(exchange, Locale.forLanguageTag("ru"))
|
||||
variables.forEach { (name, value) -> context.setVariable(name, value) }
|
||||
return engine.process(template, context)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user