Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(apply): implement the mission view page #763

Merged
merged 15 commits into from
Sep 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
"dependencies": {
"axios": "^0.21.1",
"classnames": "^2.3.1",
"github-markdown-css": "^5.6.1",
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

기존 과제 내용이 Github 저장소 README.md의 스타일링을 고려하여 작성한 내용이다 보니, 그대로 스타일을 가져오는 것이 콘텐츠 제공자와 사용자 모두의 입장에서 좋을 것이라고 생각했어요.

  • github-markdown-css 라이브러리는 Github 저장소에 적용되는 스타일을 그대로 사용할 수 있습니다.
  • 다크 모드를 지원하기 때문에 추후 플랫폼 내 다크 모드 스타일링을 도입한다면, 바로 다크 모드 스타일을 적용할 수 있습니다

Copy link
Contributor

@woowapark woowapark Aug 25, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

c: 해당 라이브러리를 선택하신 이유가 있을까요?
저도 정확히 같은 기능을 구현한 적은 없어서 리뷰하며 잠시 찾아보았는데, 아래와 같이 React 컴포넌트 형식으로, 코드 하이라이팅을 함께 지원하는 라이브러리도 있길래
해당 라이브러리를 최종 결정하신 이유도 궁금합니다.
https://www.npmjs.com/package/@uiw/react-markdown-preview

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

라이브러리를 선택한 배경에 관해 궁금해하시는 것 같습니다!
서버에서는 DB에 마크다운 형식으로 가지고 있지만, 클라이언트에 전달할 때는 html로 변환해서 던져주게 됩니다.
그래서 이 상황에 적합한 라이브러리는 github-markdown-css 라고 판단했습니다 🙂

"highlight.js": "^11.10.0",
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

그런데 github-markdown-css는 코드 블록 스타일링은 지원하지 않아서 추가적으로 highlight.js 라이브러리를 추가적으로 도입하게 되었습니다.

우아한테크코스에서 사용하는 Java, Javascript, Kotlin의 언어 모두 지원합니다(언어 지원 목록).

"react": "^18.2.0",
"react-datepicker": "^4.2.1",
"react-dom": "^18.2.0",
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/App.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import RecruitmentProvider from "./provider/RecruitmentProvider";
import TokenProvider from "./provider/TokenProvider";
import MemberInfoProvider from "./provider/MemberInfoProvider";
import { ModalProvider } from "./hooks/useModalContext";
import MissionView from "./pages/MissionView/MissionView";

const App = () => {
return (
Expand All @@ -45,6 +46,7 @@ const App = () => {
<Route path={PATH.EDIT_PASSWORD} element={<PasswordEdit />} />
<Route path={PATH.MY_APPLICATION} element={<MyApplication />} />
<Route path={PATH.ASSIGNMENT} element={<AssignmentSubmit />} />
<Route path={PATH.MISSION_VIEW} element={<MissionView />} />
</Route>

<Route element={<PrivateRoute />}>
Expand Down
15 changes: 15 additions & 0 deletions frontend/src/api/recruitments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,11 @@ export type PostJudgmentRequest = RequestWithToken<{

export type PostJudgmentResponseData = Mission["judgment"];

export type FetchMissionRequest = RequestWithToken<{
recruitmentId: string;
missionId: number;
}>;

export const fetchRecruitmentItems = (recruitmentId: FetchRecruitmentItemsRequest) =>
axios.get<FetchRecruitmentItemsResponseData>(`/api/recruitments/${recruitmentId}/items`);

Expand Down Expand Up @@ -117,3 +122,13 @@ export const patchAssignment = ({
assignmentData,
headers({ token })
);

export const fetchMissionRequirements = ({
token,
recruitmentId,
missionId,
}: FetchAssignmentRequest) =>
axios.get<Mission>(
`/api/recruitments/${recruitmentId}/missions/${missionId}/me`,
headers({ token })
);
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
.assignment-button {
background-color: var(--blue);
}

.refresh-button {
background-color: var(--blue-gray-500);
}
Expand All @@ -11,6 +15,7 @@
}

@media screen and (max-width: 800px) {
.assignment-button,
.judgment-button,
.apply-button,
.refresh-button {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,15 @@ const MyMissionItem = ({ mission, recruitmentId }: MyMissionItemProps) => {
);
};

const routeToMissionView = () => {
navigate(
generatePath(PATH.MISSION_VIEW, {
recruitmentId,
missionId: String(mission.id),

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a: missionId를 문자열로 변환하고 있는데, 이 부분이 꼭 필요한 것일까요~?(제가 코드를 잘 몰라서 하는 질문일수도 있습니다~!) 만약 API나 다른 코드에서 이 변환이 반드시 필요하다면 실수를 줄이기 위한 코드를 추가해 볼 수도 있을것 같아 보여요~!

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

추후 타입 정리도 리팩터링 작업 진행해보겠습니다~!

})
);
};

return (
<div className={classNames(styles["content-box"])}>
<div className={styles["content-wrapper"]}>
Expand All @@ -49,6 +58,15 @@ const MyMissionItem = ({ mission, recruitmentId }: MyMissionItemProps) => {
{missionItem.title}
</RecruitmentDetail>
<ul className={styles["title-button-list"]}>
<li>
<Button
className={buttonStyles["assignment-button"]}
disabled={missionItem.status !== MISSION_STATUS.SUBMITTING}
onClick={routeToMissionView}
>
과제 보기
</Button>
</li>
<li>
<Button
className={buttonStyles["apply-button"]}
Expand Down
1 change: 1 addition & 0 deletions frontend/src/constants/path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export const PATH = {
SIGN_UP: "/sign-up",
MY_APPLICATION: "/applications/me",
ASSIGNMENT: "/assignment/:status",
MISSION_VIEW: "/assignment/:recruitmentId/mission/:missionId",

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a: 혹시 assignment/:assignmentId 또는 recruitment/:recruitmentId 가아닌, /assignment/:recruitmentId 이런 계층 구조여야하는 이유가 있는 것일까요~? 🤔 다소 헷갈리는 느낌이 있는데, 아마 이미 도메인 관련해서 논의 및 의사결정이 있었을 것 같아요. 과제 안에 여러개의 미션이 있는 의미일것 같긴한데 참고용 질문으로만 남겨봅니다~!

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

QA 진행 기간 중에 확인해 보고 코멘트 달아두겠습니다~!

APPLICATION_FORM: "/application-forms/:status",
LOGIN: "/login",
FIND_PASSWORD: "/find",
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/hooks/useMission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ type MissionProps = {
recruitmentId: string;
};

const missionLabel = (submitted: boolean, missionStatus: Mission["status"]) => {
export const getMissionLabel = (submitted: boolean, missionStatus: Mission["status"]) => {
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 함수는 MissionView.tsx에서도 쓰입니다. 이 함수가 지금 useMission 훅에 존재하는 것이 적절하지는 않아보이는데 마땅히 어느 곳에 있어야 할지 판단이 안 서네요😓

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a:

  • MissionView.tsx에서 쓰이는 것 때문에 현재 위치가 적절해보이지 않는다는 의미이실까요? 어떤 이유에서 그렇게 보게 되셨는지 궁금합니다.
  • 다만 적절함 유무와 별개로 이 함수의 이름을 변경하는 것은 refactor 작업이라고 생각해요. 현재 PR은 과제 보기 기능 구현이기 때문에 수정한다면 별도 작업에서 진행해도 되지 않을까 의견 남겨둡니당

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • 미션 제출 상태에 따라 적합한 레이블 텍스트를 뽑아 내는 함수인데 이것이 훅이 가지고 있는 함수라기보다는, 유틸 함수로 빼야 하는 것 아닌가? 하는 고민에서 시작되었습니다.
  • 필요하다면, 별도 이슈에서 작업할게요~

const labelMap = {
SUBMITTABLE: BUTTON_LABEL.BEFORE_SUBMIT,
SUBMITTING: submitted ? BUTTON_LABEL.EDIT : BUTTON_LABEL.SUBMIT,
Expand All @@ -22,7 +22,7 @@ const missionLabel = (submitted: boolean, missionStatus: Mission["status"]) => {
const useMission = ({ mission, recruitmentId }: MissionProps) => {
const [missionItem, setMissionItem] = useState<Mission>({ ...mission });

const applyButtonLabel = missionLabel(mission.submitted, mission.status);
const applyButtonLabel = getMissionLabel(mission.submitted, mission.status);

useEffect(() => {
setMissionItem(mission);
Expand Down
19 changes: 19 additions & 0 deletions frontend/src/pages/MissionView/MissionView.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
.buttons {
width: 100%;
margin-top: 3.5rem;
display: flex;
justify-content: space-between;
gap: 1rem;
}

.buttons li {
flex: 1;
}

.buttons li > button {
width: 100%;
}

.mission-viewer-body li {
list-style: disc;
}
118 changes: 118 additions & 0 deletions frontend/src/pages/MissionView/MissionView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { generatePath, useNavigate, useParams } from "react-router-dom";
import Button, { BUTTON_VARIANT } from "../../components/@common/Button/Button";
import Container from "../../components/@common/Container/Container";
import useTokenContext from "../../hooks/useTokenContext";
import styles from "./MissionView.module.css";
import { PARAM, PATH } from "../../constants/path";
import { fetchMissionRequirements } from "../../api";
import { useEffect, useState } from "react";
import highlighter from "highlight.js";
import "github-markdown-css/github-markdown-light.css";
import "highlight.js/styles/github.css";
import { Mission } from "../../../types/domains/recruitments";
import { getMissionLabel } from "../../hooks/useMission";
import { MISSION_STATUS } from "../../constants/recruitment";
import { AxiosError } from "axios";

const MissionView = () => {
const { token } = useTokenContext();
const navigate = useNavigate();

const { recruitmentId, missionId } = useParams<{ recruitmentId: string; missionId: string }>();
const [mission, setMission] = useState<Mission | null>(null);
const description = mission?.description ?? "";

const isMissionSubmittable =
!mission ||
mission?.submittable ||
mission?.status === MISSION_STATUS.SUBMITTABLE ||
mission?.status === MISSION_STATUS.SUBMITTING;

const missionLabel = getMissionLabel(
mission?.submitted ?? false,
mission?.status ?? MISSION_STATUS.UNSUBMITTABLE
);

const goBack = () => {
navigate(-1);
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

명시적으로 내 지원 현황 페이지인 /applications/me 경로로 라우팅하는 것이 더 적절할까요?

image
Suggested change
navigate(-1);
navigate('/applications/me'); // (예시)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a:

  • '뒤로 가기' 기능이 의도라면 현재가 더 적절하다고 생각합니다.
  • 내 지원현황 페이지로 명식적으로 이동시키는 것이 의도라면, 함수 이름도 그에 맞춰서 같이 바꾸는 게 더 혼동의 여지가 적다고 생각해요 :)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 의도에 대해서는 구체적으로 논의된 적이 없어서요!
일단 '뒤로 가기'기능으로 구동하더라도 큰 문제가 되지 않으니 적용해 보고 적절치 않으면 다음 이슈에서 진행해보는 것이 좋을 것 같아요 😁

};

const routeToAssignmentSubmit = () => {
const isSubmitted = mission?.submitted;

navigate(
{
pathname: generatePath(PATH.ASSIGNMENT, {
status: isSubmitted ? PARAM.ASSIGNMENT_STATUS.EDIT : PARAM.ASSIGNMENT_STATUS.NEW,
}),
},
{
state: {
recruitmentId,
currentMission: mission,
},
}
);
};

const fetchRequirement = async () => {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

c: MissionView라는 이름은 과제 보기 페이지를 렌더링하는 역할을 주로 수행할 것으로 예상되는데 컴포넌트 안에서 데이터 불러오기, 네비게이션, 상태 관리 등 다양한 로직을 포함하고 있는 것 같아요~!

데이터를 불러오는 로직이나 네비게이션 로직을 별도의 커스텀 훅으로 분리해보는건 어떨까요~? 재사용할 계획이 없다면, 분리하지 않을 수 있겠으나, 현재는 컴포넌트 네임의 역할을 확인하는데 한 곳에서 많은 코드를 봐야하는것 같아 제안드려 봅니다~!

Copy link
Author

@woowahan-cron woowahan-cron Sep 3, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

공원의 리뷰와 비슷한 관점에서 말씀해 주신 것 같아요!

이 부분은 앞으로의 리팩터링 작업에서 꼭 개선해 나가야 할 방향이 아닐까 싶어요~! 그때까지는 현재의 구조를 유지하면서 점진적으로 개선해 보겠습니다~!

if (!recruitmentId || !missionId) {
return;
}

try {
const response = await fetchMissionRequirements({
token,
recruitmentId: parseInt(recruitmentId, 10),
missionId: parseInt(missionId, 10),
});

setMission(response?.data);
} catch (error) {
alert((error as AxiosError).response?.data.message);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

c: AxiosError인지 등은 UI Component가 신경쓰지 않아도 되는 부분이지 않을까요? api 레이어에 있는 fetchMissionRequirements 에서 필요한 검증은 마치고, 컴포넌트에서는 에러 메지시 노출, 페이지 이동 등 UI와 관련된 부분만 신경쓰도록 하는 것이 좋다고 생각합니다~!

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

아, 맞아요! UI Component가 오류 형식을 받아서 메시지만 보여주는 게 가장 이상적인 구조라는 데 동의해요~

지원플랫폼에서는 대부분 서버에서 받아온 오류 메시지를 그대로 표시하기보다는, 오류가 발생하면 클라이언트에서 정의한 메시지 상수를 보여주는 구조로 되어 있더라고요. 서버에서 받아온 오류 메시지를 직접 표현하는 처리는 몇 군데 없는 편이에요.

그런데 현재로서는 API 레이어에서 catch하는 구조가 아직 완벽하게 갖춰지지 않은 것 같아요! 이 부분은 앞으로의 리팩터링 작업에서 꼭 개선해 나가야 할 방향이 아닐까 싶어요~! 그때까지는 현재의 구조를 유지하면서 점진적으로 개선해 나가는 게 좋을 것 같아요 😊


goBack();
}
};

useEffect(() => {
if (!recruitmentId || !missionId) {
goBack();
return;
}

fetchRequirement();
}, []);

useEffect(() => {
/*
url - https://highlightjs.org/
<pre><code> 태그 내의 코드를 자동으로 감지하여 하이라이팅합니다.
자동 감지가 실패할 경우, class 속성에 언어를 명시적으로 지정할 수 있습니다.
*/
highlighter.highlightAll();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

c: highlight 대상은 어떻게 찾나요 ?-?

라이브러리 문서에 아래와 같이 안내되고 있긴 하군요 🤔

This will find and highlight code inside

 tags; it tries to detect the language automatically. If automatic detection does not work for you, you can specify the language in the class attribute:

이후에 어떤 식으로 적용되는지 코드만으로 이해하기 어려울 수 있을 것 같아, 이 방식이 유지된다면 주석을 같이 남겨두어도 좋을 것 같습니다.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

주석에 간단히 남겨둘게요~

}, [description]);

return (
<Container>
<div
className={`${styles["mission-viewer-body"]} markdown-body`}
dangerouslySetInnerHTML={{ __html: description }}
/>
<ul className={styles.buttons}>
<li>
<Button type="button" variant={BUTTON_VARIANT.OUTLINED} onClick={goBack}>
뒤로가기
</Button>
</li>
<li>
<Button type="button" onClick={routeToAssignmentSubmit} disabled={!isMissionSubmittable}>
{missionLabel}
</Button>
</li>
</ul>
</Container>
);
};

export default MissionView;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

c: page 컴포넌트들은 Storybook도 같이 넣어두고 있었는데요. 이후 테스트 보강을 같이 해두면 좋을 것 같습니다.

10 changes: 10 additions & 0 deletions frontend/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -8422,6 +8422,11 @@ giget@^1.0.0:
pathe "^1.1.0"
tar "^6.1.13"

github-markdown-css@^5.6.1:
version "5.6.1"
resolved "https://registry.yarnpkg.com/github-markdown-css/-/github-markdown-css-5.6.1.tgz#8ca3d5c3d93d79ea429fddafea091347ab374f78"
integrity sha512-DItLFgHd+s7HQmk63YN4/TdvLeRqk1QP7pPKTTPrDTYoI5x7f/luJWSOZxesmuxBI2srHp8RDyoZd+9WF+WK8Q==

github-slugger@^1.0.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/github-slugger/-/github-slugger-1.4.0.tgz#206eb96cdb22ee56fdc53a28d5a302338463444e"
Expand Down Expand Up @@ -8660,6 +8665,11 @@ headers-polyfill@^3.1.0:
resolved "https://registry.yarnpkg.com/headers-polyfill/-/headers-polyfill-3.1.1.tgz#798e265f80edfc53fb8fd01e8963b356b12c2514"
integrity sha512-ifvvIC+VDeLTEkJDwxECSI7k9rJF7sJavCh/UfhGsgJ+LXMbGILRf+NXhc4znuf+JA5X2/leQdOXk6Lq6Y2/wQ==

highlight.js@^11.10.0:
version "11.10.0"
resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-11.10.0.tgz#6e3600dc4b33d6dc23d5bd94fbf72405f5892b92"
integrity sha512-SYVnVFswQER+zu1laSya563s+F8VDGt7o35d4utbamowvUNLLMovFqwCLSocpZTz3MgaSRA1IbqRWZv97dtErQ==

history@^5.2.0:
version "5.3.0"
resolved "https://registry.yarnpkg.com/history/-/history-5.3.0.tgz#1548abaa245ba47992f063a0783db91ef201c73b"
Expand Down
2 changes: 1 addition & 1 deletion src/main/kotlin/apply/application/MyMissionService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ class MyMissionService(
private fun findMissions(memberId: Long, recruitmentId: Long): List<Mission> {
val evaluationIds = evaluationRepository.findAllByRecruitmentId(recruitmentId).map { it.id }
val targets = evaluationTargetRepository.findAllByMemberIdAndEvaluationIdIn(memberId, evaluationIds)
return missionRepository.findAllByEvaluationIdIn(targets.map { it.id }).filterNot { it.hidden }
return missionRepository.findAllByEvaluationIdIn(targets.map { it.evaluationId }).filterNot { it.hidden }
}

private fun List<Mission>.mapBy(assignments: List<Assignment>): List<MyMissionAndJudgementResponse> {
Expand Down
42 changes: 42 additions & 0 deletions src/main/kotlin/apply/config/DatabaseInitializer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,48 @@ class DatabaseInitializer(
|- 미션은 **기능 요구 사항, 프로그래밍 요구 사항, 과제 진행 요구 사항** 세 가지로 구성되어 있다.
|- 세 개의 요구 사항을 만족하기 위해 노력한다. 특히 기능을 구현하기 전에 기능 목록을 만들고, 기능 단위로 커밋 하는 방식으로 진행한다.
|- 기능 요구 사항에 기재되지 않은 내용은 스스로 판단하여 구현한다.
|
|---
|
|## 🎯 프로그래밍 요구 사항
|
|### 라이브러리
|
|- `camp.nextstep.edu.missionutils`에서 제공하는 `Randoms` 및 `Console` API를 사용하여 구현해야 한다.
| - Random 값 추출은 `camp.nextstep.edu.missionutils.Randoms`의 `pickNumberInRange()`를 활용한다.
| - 사용자가 입력하는 값은 `camp.nextstep.edu.missionutils.Console`의 `readLine()`을 활용한다.
|
|#### 사용 예시
|
|```java
|List<Integer> computer = new ArrayList<>();
|while (computer.size() < 3) {
| int randomNumber = Randoms.pickNumberInRange(1, 9);
| if (!computer.contains(randomNumber)) {
| computer.add(randomNumber);
| }
|}
|```
|
|```javascript
|const computer = [];
|while (computer.length < 3) {
| const number = MissionUtils.Random.pickNumberInRange(1, 9);
| if (!computer.includes(number)) {
| computer.push(number);
| }
|}
|```
|
|```kotlin
|val computer = mutableListOf()
|while (computer.size() < 3) {
| val randomNumber = Randoms.pickNumberInRange(1, 9)
| if (!computer.contains(randomNumber)) {
| computer.add(randomNumber)
| }
|}
|```
""".trimMargin(),
evaluationId = 2L,
startDateTime = createLocalDateTime(2020, 11, 24, 15),
Expand Down
24 changes: 24 additions & 0 deletions src/test/kotlin/support/MarkdownTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package support

import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
import io.kotest.matchers.string.shouldContainInOrder
import io.kotest.matchers.string.shouldNotContain

class MarkdownTest : StringSpec({
Expand Down Expand Up @@ -61,4 +63,26 @@ class MarkdownTest : StringSpec({
val actual = markdownToEmbeddedHtml(markdownText)
actual shouldNotContain "<body>"
}

"코드 포맷을 사용하는 경우 <code> 태그가 추가된다" {
val markdownText = """
|`buildSrc`
""".trimMargin()
val actual = markdownToEmbeddedHtml(markdownText)
actual shouldContain "<code>buildSrc</code>"
}

"프로그래밍 언어를 지정하면 <code> 태그에 클래스 속성이 지정된다" {
val markdownText = """
|```kotlin
|class Cat {
| fun purr() {
| println("Purr purr")
| }
|}
|```
""".trimMargin()
val actual = markdownToEmbeddedHtml(markdownText)
actual.shouldContainInOrder("<pre>", "<code class=\"language-kotlin\">", "</code>", "</pre>")
}
})